diff --git a/.gitignore b/.gitignore index 8d7f9e8c..9282e4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ memory/L4_raw_sessions/* # Subagent SOP !memory/subagent_sop.md +# Incubator SOP +!memory/incubator_sop.md + # Supervisor SOP !memory/supervisor_sop.md diff --git a/README.md b/README.md index cc90fe5e..8ab44455 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ You're also welcome to join the **GenericAgent Community Group** for discussion,
- +
WeChat Group 18
WeChat Group 18 QR
WeChat Group 19
WeChat Group 19 QR
@@ -742,7 +742,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
- +
微信群 18
微信群 18 二维码
微信群 19
微信群 19 二维码
@@ -757,6 +757,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环** - [chilishark27/ga-manager](https://github.com/chilishark27/ga-manager) - [wangjc683/galley](https://github.com/wangjc683/galley) +- https://github.com/FroStorM/A3Agent/tree/workbench --- diff --git a/agent_loop.py b/agent_loop.py index 3011735f..7afb5850 100644 --- a/agent_loop.py +++ b/agent_loop.py @@ -1,6 +1,8 @@ import json, re, os from dataclasses import dataclass from typing import Any, Optional +try: from plugins.hooks import trigger as _hook +except ImportError: _hook = lambda *a, **k: None @dataclass class StepOutcome: data: Any @@ -12,16 +14,14 @@ def try_call_generator(func, *args, **kwargs): return ret class BaseHandler: - def tool_before_callback(self, tool_name, args, response): pass - def tool_after_callback(self, tool_name, args, response, ret): pass def turn_end_callback(self, response, tool_calls, tool_results, turn, next_prompt, exit_reason): return next_prompt def dispatch(self, tool_name, args, response, index=0, tool_num=1): method_name = f"do_{tool_name}" if hasattr(self, method_name): args['_index'] = index; args['_tool_num'] = tool_num - prer = yield from try_call_generator(self.tool_before_callback, tool_name, args, response) + _hook('tool_before', locals()) ret = yield from try_call_generator(getattr(self, method_name), args, response) - _ = yield from try_call_generator(self.tool_after_callback, tool_name, args, response, ret) + _hook('tool_after', locals()) return ret elif tool_name == 'bad_json': return StepOutcome(None, next_prompt=args.get('msg', 'bad_json'), should_exit=False) else: @@ -46,6 +46,7 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, {"role": "user", "content": initial_user_content if initial_user_content is not None else user_input} ] turn = 0; handler.max_turns = max_turns + _hook('agent_before', locals()) while turn < handler.max_turns: turn += 1; turnstr = f'LLM Running (Turn {turn}) ...' if handler.parent.task_dir: turnstr = f'Turn {turn} ...' @@ -53,6 +54,8 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, if yield_info: yield {'turn': turn} yield f"\n\n{turnstr}\n\n" if turn%10 == 0: client.last_tools = '' # 每10轮重置一次工具描述 + _hook('turn_before', locals()) + _hook('llm_before', locals()) response_gen = client.chat(messages=messages, tools=tools_schema) if verbose: response = yield from response_gen @@ -61,6 +64,7 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, response = exhaust(response_gen) cleaned = _clean_content(response.content) if cleaned: yield cleaned + '\n' + _hook('llm_after', locals()) if not response.tool_calls: tool_calls = [{'tool_name': 'no_tool', 'args': {}}] else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id} @@ -96,8 +100,10 @@ def agent_runner_loop(client, system_prompt, user_input, handler, tools_schema, if len(handler._done_hooks) == 0 or exit_reason.get('result', '') == 'EXITED': break next_prompts.add(handler._done_hooks.pop(0)) next_prompt = handler.turn_end_callback(response, tool_calls, tool_results, turn, '\n'.join(next_prompts), exit_reason) + _hook('turn_after', locals()) messages = [{"role": "user", "content": next_prompt, "tool_results": tool_results}] # just new message, history is kept in *Session if exit_reason: handler.turn_end_callback(response, tool_calls, tool_results, turn, '', exit_reason) + _hook('agent_after', locals()) return exit_reason or {'result': 'MAX_TURNS_EXCEEDED'} def _clean_content(text): diff --git a/agentmain.py b/agentmain.py index 1403f4b8..cf1d47c0 100644 --- a/agentmain.py +++ b/agentmain.py @@ -6,8 +6,11 @@ if sys.stderr is None: sys.stderr = open(os.devnull, "w") elif hasattr(sys.stderr, 'reconfigure'): sys.stderr.reconfigure(errors='replace') sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from llmcore import reload_mykeys, LLMSession, ToolClient, ClaudeSession, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client +from llmcore import reload_mykeys, ToolClient, MixinSession, NativeToolClient, NativeClaudeSession, NativeOAISession, resolve_client from agent_loop import agent_runner_loop +try: + from plugins.hooks import discover_and_load; discover_and_load() +except Exception: pass from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file script_dir = os.path.dirname(os.path.abspath(__file__)) diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 6055a627..175d7199 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -7,6 +7,7 @@ GenericAgent — 交互式初始化向导 (configure.py) python configure.py """ +import ast import os import sys import re @@ -857,6 +858,8 @@ def configure_platforms(): if pid == 'feishu' and ask_yesno("使用一键扫码创建应用?(推荐)", default=True): env_vals = _feishu_scan(platform) + if pid == 'wechat' and ask_yesno("扫码登录微信 iLink?(推荐)", default=True): + env_vals = _wechat_scan() for var in platform['env_vars']: if var['key'] not in env_vals: @@ -946,6 +949,39 @@ def _feishu_scan(platform): return {} +def _wechat_scan(): + """微信 iLink 扫码登录,保存 token 到 ~/.wxbot/token.json,返回 env_vals""" + print(f"\n {C['cyan']}📱 正在启动微信 iLink 扫码登录...{C['reset']}") + print(f" {C['dim']} 请用微信扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n") + + # 确保项目根在路径中,以便导入 frontends/wechatapp + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + try: + from frontends.wechatapp import WxBotClient + except ImportError as e: + print(f"\n {C['yellow']}⚠ 无法导入 WxBotClient: {e}{C['reset']}") + return {} + + try: + bot = WxBotClient() + if bot.token: + print(f" {C['green']}✅ 已有有效 token (bot_id={bot.bot_id}){C['reset']}") + if ask_yesno("重新扫码登录?", default=False): + bot.token = '' + else: + return {} + bot.login_qr() + print(f"\n {C['green']}✅ 微信 iLink 扫码登录成功!{C['reset']}") + print(f" Bot ID: {C['bold']}{bot.bot_id}{C['reset']}") + print(f" Token 已保存到: {C['dim']}{bot._tf}{C['reset']}") + except Exception as e: + print(f"\n {C['red']}✗ 扫码登录失败: {e}{C['reset']}") + return {} + + return {} + + # ═══════════════════════════════════════════════════════════════════════════ # 生成 mykey.py @@ -1033,7 +1069,7 @@ def generate_mykey(llm_cfgs, platform_configs): def _write_config_fields(lines, cfg): """写入配置字典的键值对(缩进的 'key': value, 格式)""" - for key in ['name', 'apikey', 'apibase', 'model', 'api_mode', + for key in ['name', 'type', 'apikey', 'apibase', 'model', 'api_mode', 'fake_cc_system_prompt', 'thinking_type', 'thinking_budget_tokens', 'reasoning_effort', 'max_tokens', 'max_retries', 'connect_timeout', 'read_timeout', 'temperature', 'context_win', @@ -1066,7 +1102,7 @@ def _write_platform_value(lines, key, val): def _parse_existing_mykey(): """解析已有 mykey.py,返回 (model_names, platform_infos) - llm_cfgs: [{'name': str, 'type': str, ...}] — 模型配置字典列表 + model_names: [str] — 模型名列表 platform_infos: [{'id': str, 'vars': [{'key': str, 'val': ...}]}] — 平台信息 解析失败时返回 ([], []) """ @@ -1082,21 +1118,81 @@ def _parse_existing_mykey(): if m: model_names = re.findall(r"'([^']+)'", m.group(1)) - # 解析平台变量 → 平台 ID - platform_id_map = { - 'tg_bot_token': 'telegram', 'qq_app_id': 'qq', - 'fs_app_id': 'feishu', 'wecom_bot_id': 'wecom', - 'dingtalk_client_id': 'dingtalk', 'dc_bot_token': 'discord', - } + # 先收集所有已知平台 env var key → 判断值类型 + all_env_var_keys = {} + platform_env_keys = {} # pid -> [var_key] + for p in PLATFORMS: + pid = p['id'] + platform_env_keys.setdefault(pid, []) + for var in p.get('env_vars', []): + vkey = var['key'] + all_env_var_keys[vkey] = var + platform_env_keys[pid].append(vkey) + + # 逐平台解析所有已知变量 platform_infos = [] - for var_key, pid in platform_id_map.items(): - m_var = re.search(rf"^{var_key}\s*=\s*'([^']*)'", content, re.MULTILINE) - if m_var: - platform_infos.append({'id': pid, 'vars': [{'key': var_key, 'val': m_var.group(1)}]}) + for pid, env_keys in platform_env_keys.items(): + vars_found = [] + for vkey in env_keys: + var_def = all_env_var_keys[vkey] + val = None + if var_def.get('is_list'): + # 匹配 `xxx = [...]` + m_var = re.search(rf"^{vkey}\s*=\s*(\[[^\]]*\])", content, re.MULTILINE) + if m_var: + try: + val = ast.literal_eval(m_var.group(1)) + except (ValueError, SyntaxError): + pass + else: + # 匹配 `xxx = '...'` + m_var = re.search(rf"^{vkey}\s*=\s*'([^']*)'", content, re.MULTILINE) + if m_var: + val = m_var.group(1) + if val is not None: + vars_found.append({'key': vkey, 'val': val}) + if vars_found: + platform_infos.append({'id': pid, 'vars': vars_found}) return model_names, platform_infos +def _parse_existing_llm_cfgs(): + """解析已有 mykey.py,返回完整 LLM 配置字典列表 [{name, apikey, ...}] + 解析失败时返回 [] + """ + if not os.path.exists(MYKPY_PATH): + return [] + + with open(MYKPY_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + cfgs = [] + # 匹配所有 `xxx = { ... }` 顶层字典赋值 + # 用简单状态机: 找 `\w+ = {` 然后匹配花括号 + pattern = re.compile(r'^(\w+)\s*=\s*\{', re.MULTILINE) + for m in pattern.finditer(content): + brace_start = m.end() - 1 # '{' 的位置 + depth = 1 + i = brace_start + 1 + while i < len(content) and depth > 0: + if content[i] == '{': + depth += 1 + elif content[i] == '}': + depth -= 1 + i += 1 + if depth == 0: + dict_text = content[m.end():i - 1] + try: + d = ast.literal_eval('{' + dict_text + '}') + if isinstance(d, dict) and 'name' in d: + cfgs.append(d) + except (ValueError, SyntaxError): + continue + + return cfgs + + def _backup_with_name(model_names, platform_ids): """按 mykey+模型名+机器人名 格式备份旧 mykey.py""" parts = ['mykey'] @@ -1107,6 +1203,8 @@ def _backup_with_name(model_names, platform_ids): if pid_clean not in parts: parts.append(pid_clean) safe_name = '_'.join(parts) + if safe_name == 'mykey': + safe_name = 'mykey_backup' # 避免和源文件同名 if len(safe_name) > 100: safe_name = safe_name[:100] backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py') @@ -1154,7 +1252,7 @@ def main(): if mode == 'new': backup_path = _backup_with_name(model_names, [p['id'] for p in platform_infos]) - print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") + print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup_path}{C['reset']}") is_new = True else: is_modify = True @@ -1177,8 +1275,12 @@ def main(): config_dict = {v['key']: v['val'] for v in pi['vars']} platform_configs.append({'platform': p, 'config': config_dict}) elif scope == 'platform' and model_names: - print(f"\n {C['yellow']}⚠ 只修改平台时若未提供 LLM 配置将无法使用。{C['reset']}") - cprint(f" 建议两项都重新配置。", 'dim') + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留现有 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") + else: + print(f"\n {C['yellow']}⚠ 保留 LLM 配置失败,将生成空配置。建议两项都重新配置。{C['reset']}") if not is_modify: if is_new: @@ -1210,6 +1312,12 @@ def main(): platform_configs, platform_deps = configure_platforms() if ask_yesno("是否继续配置 LLM 模型?", default=True): llm_cfgs = _do_llm() + elif os.path.exists(MYKPY_PATH): + # 新建+仅平台:从备份保留旧 LLM 配置 + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留备份中的 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") # ── 生成 mykey.py ── if not llm_cfgs and not platform_configs: @@ -1218,10 +1326,9 @@ def main(): content = generate_mykey(llm_cfgs, platform_configs) - # 备份旧文件 - if os.path.exists(MYKPY_PATH): - backup = os.path.join(PROJECT_ROOT, f'mykey.py.bak.{datetime.now().strftime("%Y%m%d_%H%M%S")}') - shutil.copy2(MYKPY_PATH, backup) + # 备份旧文件(修改模式不备份,直接在原文件修改) + if os.path.exists(MYKPY_PATH) and not is_modify and not is_new: + backup = _backup_with_name(model_names, [p['id'] for p in platform_infos]) print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") # 写入 diff --git a/assets/images/wechat_group19.jpg b/assets/images/wechat_group19.jpg new file mode 100644 index 00000000..f02e694d Binary files /dev/null and b/assets/images/wechat_group19.jpg differ 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/installation_zh.md b/docs/installation_zh.md index 1994798c..c124cc7b 100644 --- a/docs/installation_zh.md +++ b/docs/installation_zh.md @@ -204,7 +204,7 @@ uv pip install -e ".[ui]" > **重要:用 `curl` 获取本文,不要依赖会摘要网页的读取器。** 摘要常会丢掉环境变量、命令引号和安全规则。 > > ```bash -> curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/installation.zh.md +> curl -fsSL https://raw.githubusercontent.com/lsdefine/GenericAgent/refs/heads/main/docs/installation.md > ``` 你正在替人类用户安装 **GenericAgent**。请直接执行、逐步验证;除非用户明确授权,不要做破坏性清理。 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/cost_tracker.py b/frontends/cost_tracker.py index 75c7396a..74425898 100644 --- a/frontends/cost_tracker.py +++ b/frontends/cost_tracker.py @@ -8,6 +8,7 @@ runs its agent on `ga-tui-agent-`, so `/cost` is a thread lookup. Subagent processes are out-of-process, so `scan_subagent_logs` parses the same `[Cache]` / `[Output]` print lines from `temp/*/stdout.log`. """ +from __future__ import annotations import glob, os, re, threading, time from dataclasses import dataclass, field diff --git a/frontends/fsapp.py b/frontends/fsapp.py index 8fa97bee..ad959f66 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -1,22 +1,86 @@ -import glob, json, os, queue as Q, re, sys, threading, time -import atexit, hashlib -try: - import msvcrt -except Exception: - msvcrt = None +import argparse, asyncio, importlib.util, json, os, queue as Q, re, sys, threading, time, uuid +from pathlib import Path PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, PROJECT_ROOT) os.chdir(PROJECT_ROOT) -from agentmain import GeneraticAgent -from frontends.chatapp_common import format_restore -from frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation -from llmcore import mykeys import traceback import lark_oapi as lark from lark_oapi.api.im.v1 import * + +def _ensure_dir(path): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) + return path + + +def _workspace_root_dir(): + root = os.environ.get("GA_WORKSPACE_ROOT") + if root: + return _ensure_dir(Path(root).expanduser().resolve()) + return _ensure_dir(Path(PROJECT_ROOT).resolve()) + + +def _workspace_config_dir(root=None): + base = Path(root).expanduser().resolve() if root else _workspace_root_dir() + if base.name == "ga_config": + return _ensure_dir(base) + return _ensure_dir(base / "ga_config") + + +def _load_dict_config(path): + path = Path(path) + if not path.exists(): + return None + try: + if path.suffix == ".py": + mod_name = f"_fs_mykey_{uuid.uuid4().hex}" + spec = importlib.util.spec_from_file_location(mod_name, path) + if not spec or not spec.loader: + return None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + data = {k: v for k, v in vars(module).items() if not k.startswith("_")} + else: + with open(path, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else None + except Exception as e: + print(f"[ERROR] load config failed {path}: {e}") + return None + + +def _resolve_mykey_path(): + workspace_root = _workspace_root_dir() + config_root = _workspace_config_dir(workspace_root) + candidates = [ + config_root / "mykey.json", + config_root / "mykey.py", + workspace_root / "mykey.json", + workspace_root / "mykey.py", + Path(PROJECT_ROOT) / "mykey.json", + Path(PROJECT_ROOT) / "mykey.py", + ] + for candidate in candidates: + if _load_dict_config(candidate): + return candidate + return candidates[0] + + +def _ensure_runtime_paths(): + workspace_root = _workspace_root_dir() + config_root = _workspace_config_dir(workspace_root) + os.environ.setdefault("GA_WORKSPACE_ROOT", str(workspace_root)) + os.environ.setdefault("GA_USER_DATA_DIR", str(config_root)) + return str(workspace_root), str(config_root) + + +_ensure_runtime_paths() +from agentmain import GeneraticAgent +from frontends.chatapp_common import AgentChatMixin, FILE_HINT, split_text + _TAG_PATS = [r"<" + t + r">.*?" for t in ("thinking", "summary", "tool_use", "file_content")] _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"} @@ -39,74 +103,29 @@ MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media") os.makedirs(MEDIA_DIR, exist_ok=True) -def _acquire_fsapp_singleton(): - """Per-agent process lock; prevents duplicate Feishu long-connection clients.""" - if msvcrt is None: - return None - path = os.path.join(TEMP_DIR, "fsapp_singleton.lock") - f = open(path, "a+b") - try: - msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1) - except OSError: - print(f"[INFO] another fsapp is already running for {PROJECT_ROOT}; exiting duplicate pid={os.getpid()}", flush=True) - f.close() - sys.exit(0) - atexit.register(lambda: (msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1), f.close())) - return f - - -_FSAPP_LOCK = _acquire_fsapp_singleton() _TRUNC_TAIL = 300 # 截断兜底时保留原文尾部字符数 -_DEDUP_FILE = os.path.join(TEMP_DIR, "fsapp_seen_message_ids.txt") -_DEDUP_TTL_SEC = 6 * 3600 -_DEDUP_MAX = 500 +_DEDUP_TTL_SEC = 10 * 60 +_DEDUP_MAX = 2000 +_DEDUP_LOCK = threading.Lock() +_SEEN_MESSAGES = {} -def _message_claim_once(message_id): - """Return True only for the first process that claims this Feishu message_id.""" +def _claim_message_once(message_id): + """Best-effort cross-platform dedup for Feishu reconnect redeliveries.""" if not message_id: return True now = time.time() - lock_path = _DEDUP_FILE + ".lock" - lf = None - try: - if msvcrt is not None: - lf = open(lock_path, "a+b") - msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1) - rows = [] - if os.path.exists(_DEDUP_FILE): - with open(_DEDUP_FILE, "r", encoding="utf-8", errors="replace") as f: - for line in f: - parts = line.rstrip("\n").split(" ", 1) - if len(parts) != 2: - continue - try: - ts = float(parts[0]) - except Exception: - continue - mid = parts[1] - if now - ts <= _DEDUP_TTL_SEC: - rows.append((ts, mid)) - if any(mid == message_id for _, mid in rows): + with _DEDUP_LOCK: + expired = [mid for mid, ts in _SEEN_MESSAGES.items() if now - ts > _DEDUP_TTL_SEC] + for mid in expired: + _SEEN_MESSAGES.pop(mid, None) + if len(_SEEN_MESSAGES) > _DEDUP_MAX: + for mid, _ in sorted(_SEEN_MESSAGES.items(), key=lambda item: item[1])[:len(_SEEN_MESSAGES) - _DEDUP_MAX]: + _SEEN_MESSAGES.pop(mid, None) + if message_id in _SEEN_MESSAGES: return False - rows.append((now, message_id)) - rows = rows[-_DEDUP_MAX:] - tmp = _DEDUP_FILE + ".tmp" - with open(tmp, "w", encoding="utf-8") as f: - for ts, mid in rows: - f.write(f"{ts:.3f} {mid}\n") - os.replace(tmp, _DEDUP_FILE) + _SEEN_MESSAGES[message_id] = now return True - except Exception as e: - print(f"[WARN] message dedup failed: {e}") - return True - finally: - if lf is not None: - try: - msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1) - lf.close() - except Exception: - pass def _clean(text): @@ -128,7 +147,7 @@ def _display_text(text): if cleaned: return cleaned tail = (text or "").strip()[-_TRUNC_TAIL:] - return "(无文本输出)" + (f"\n…{tail}" if tail else "") + return "⚠️ 模型输出被截断或为空" + (f"\n…{tail}" if tail else "") def _to_allowed_set(value): @@ -301,26 +320,89 @@ def _extract_post_content(content_json): return "", [] -APP_ID = str(mykeys.get("fs_app_id", "") or "").strip() -APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip() -ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", [])) -PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS AGENT_TIMEOUT_SEC = 900 -agent = GeneraticAgent() -_agent_thread = threading.Thread(target=agent.run, daemon=True, name="GA-core") -_agent_thread.start() -try: - print(f"[INFO] GA core started pid={os.getpid()} thread_alive={_agent_thread.is_alive()} llm={agent.get_llm_name()}") -except Exception as e: - print(f"[WARN] GA core started but status unavailable: {e}") -client, user_tasks = None, {} +agent = None +agent_error = None +agent_thread = None +client, user_tasks, app = None, {}, None +agent_lock = threading.Lock() + + +def _load_config(): + path = _resolve_mykey_path() + if not path or not path.exists(): + return {}, str(path or "") + try: + data = _load_dict_config(path) + return data if isinstance(data, dict) else {}, str(path) + except Exception as e: + print(f"[ERROR] load mykey failed {path}: {e}") + return {}, str(path) + + +def _feishu_config(): + cfg, path = _load_config() + app_id = str(cfg.get("fs_app_id", "") or "").strip() + app_secret = str(cfg.get("fs_app_secret", "") or "").strip() + allowed = _to_allowed_set(cfg.get("fs_allowed_users", [])) + return app_id, app_secret, allowed, (not allowed or "*" in allowed), path + + +APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config() + + +def get_agent(): + global agent, agent_error, agent_thread + with agent_lock: + if agent is not None: + return agent + if agent_error: + raise RuntimeError(agent_error) + try: + agent = GeneraticAgent() + agent_thread = threading.Thread(target=agent.run, daemon=True) + agent_thread.start() + return agent + except Exception as e: + agent_error = str(e) + raise def create_client(): return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build() +def _mask_secret(value): + value = str(value or "") + if len(value) <= 8: + return "*" * len(value) + return value[:4] + "*" * (len(value) - 8) + value[-4:] + + +def check_config(init_agent=False): + app_id, app_secret, allowed, public_access, path = _feishu_config() + result = { + "config_path": path, + "app_id": app_id, + "app_secret": _mask_secret(app_secret), + "app_secret_present": bool(app_secret), + "public_access": public_access, + "allowed_users": sorted(allowed), + "ready": bool(app_id and app_secret), + } + if init_agent: + try: + ga = get_agent() + result["agent_ready"] = True + result["llm_count"] = len(ga.list_llms()) if hasattr(ga, "list_llms") else 0 + result["current_llm"] = ga.get_llm_name() if getattr(ga, "llmclient", None) else "" + except Exception as e: + result["agent_ready"] = False + result["agent_error"] = str(e) + return result + + def _card_raw(elements): return json.dumps({ "schema": "2.0", @@ -343,15 +425,12 @@ def _send_raw(receive_id, payload, msg_type, rtype): return r.data.message_id if r.data else None print(f"发送失败: {r.code}, {r.msg}") except Exception as e: - print(f"[ERROR] _send_raw 网络异常: {e}") + print(f"[ERROR] send_message failed: {e}") + traceback.print_exc() return None def _patch_card(message_id, card_json): - return _patch_card_result(message_id, card_json)[0] - - -def _patch_card_result(message_id, card_json): try: body = PatchMessageRequest.builder().message_id(message_id).request_body( PatchMessageRequestBody.builder().content(card_json).build() @@ -359,11 +438,11 @@ def _patch_card_result(message_id, card_json): r = client.im.v1.message.patch(body) if not r.success(): print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") - msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower() - return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in msg) + return r.success() except Exception as e: - print(f"[ERROR] _patch_card 网络异常: {e}") - return False, False + print(f"[ERROR] patch_card exception: {e}") + traceback.print_exc() + return False def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"): @@ -556,7 +635,6 @@ def _build_step_detail(resp, tool_calls): class _TaskCard: """飞书任务卡片:单卡片持续 patch;每步一个独立折叠面板(header 显示 summary,展开看详情)。""" _DETAIL_LIMIT = 8000 - _FINAL_LIMIT = 6000 def __init__(self, receive_id, rid_type): self.rid, self.rtype = receive_id, rid_type @@ -564,10 +642,8 @@ class _TaskCard: self.status = "🤔 思考中..." self.final = None self.msg_id = None - self.page_no = 1 - self.turn_no = 0 - self.turn_base = 1 - self.note = None + self.start_fallback_sent = False + self.final_fallback_sent = False def _step_panel(self, idx, summary, detail): detail = detail or "_(无输出)_" @@ -580,13 +656,8 @@ class _TaskCard: } def _build(self): - header = f"**{self.status}**" - if self.page_no > 1: - header += f"\n\n📄 工作卡片 {self.page_no}" - els = [{"tag": "markdown", "content": header}] - if self.note: - els.append({"tag": "markdown", "content": self.note}) - for i, (s, d) in enumerate(self.steps, self.turn_base): + els = [{"tag": "markdown", "content": f"**{self.status}**"}] + for i, (s, d) in enumerate(self.steps, 1): els.append(self._step_panel(i, s, d)) if self.final: els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}] @@ -595,92 +666,53 @@ class _TaskCard: def _push(self): card = self._build() if self.msg_id: - return _patch_card_result(self.msg_id, card) + ok = _patch_card(self.msg_id, card) else: self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype) - return bool(self.msg_id), False + ok = bool(self.msg_id) + return ok - def _rollover(self): - self.page_no += 1 - self.msg_id = None - self.final = None - self.note = "⚠️ 上一张工作卡片达到飞书限制,本页继续展示后续进展。" + def _fallback_text(self, text, *, final=False): + attr = "final_fallback_sent" if final else "start_fallback_sent" + if getattr(self, attr): + return + setattr(self, attr, True) + send_message(self.rid, text, receive_id_type=self.rtype) # ── 公开接口 ── def start(self): - self._push() + if not self._push(): + self._fallback_text("🤔 思考中...") def step(self, summary, detail=""): - self.turn_no += 1 - step = (summary, detail) - self.steps.append(step) - self.status = f"⏳ 工作中 · Turn {self.turn_no}" - ok, limit = self._push() - if limit: - self.steps.pop() - self._rollover() - self.turn_base = self.turn_no - self.steps = [step] - self._push() + self.steps.append((summary, detail)) + self.status = f"⏳ 工作中 · Turn {len(self.steps)}" + self._push() def done(self, text): self.status = "✅ 已完成" - self.final = (text or "_(无文本输出)_")[:self._FINAL_LIMIT] - ok, limit = self._push() - if limit: - self._rollover() - self.steps = [] - self.turn_base = self.turn_no + 1 - self.final = (text or "_(无文本输出)_")[:self._FINAL_LIMIT] - ok, _ = self._push() - # Last-resort delivery: if card creation/patch fails (common after cold boot - # or message-card expiry), still send a plain text reply so the user is not - # left with no response. - if not ok: - try: - send_message(self.rid, _display_text(text), receive_id_type=self.rtype) - except Exception as e: - print(f"[ERROR] done fallback send failed: {e}") + self.final = text or "_(无文本输出)_" + if not self._push(): + self._fallback_text(_display_text(text), final=True) def fail(self, msg): self.status = f"❌ {msg}" - self._push() + if not self._push(): + self._fallback_text(f"❌ {msg}", final=True) -def _make_task_hook(card, done_event, on_final): +def _make_task_hook(card, task_id, on_final): """飞书任务 hook:每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。""" def hook(ctx): try: + parent = getattr(ctx.get("self"), "parent", None) + if getattr(parent, "_fs_active_task_id", None) != task_id: + return if ctx.get('exit_reason'): resp = ctx.get('response') raw = resp.content if hasattr(resp, 'content') else str(resp) - display = _display_text(raw) - if display.startswith("(无文本输出)"): - # fallback: show thinking + tool_calls when content is empty after cleaning - parts = [] - thinking = getattr(resp, 'thinking', '') or '' - if thinking: - parts.append(f"**[Thinking]**\n{thinking.strip()}") - tool_calls = ctx.get('tool_calls') or [] - for tc in tool_calls: - name = tc.get('tool_name') or tc.get('name', '?') - args = tc.get('args') or tc.get('arguments') or {} - if name == 'ask_user': - q = args.get('question', '') - candidates = args.get('candidates') or [] - if candidates: - q += '\n' + '\n'.join(f'- {c}' for c in candidates) - parts.append(q) - else: - args_s = json.dumps(args, ensure_ascii=False) - if len(args_s) > 200: - args_s = args_s[:200] + '...' - parts.append(f"`{name}`: {args_s}") - display = "\n\n".join(p for p in parts if p) or display - card.done(display) on_final(raw) - done_event.set() elif ctx.get('summary'): detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or []) card.step(ctx['summary'], detail) @@ -689,11 +721,98 @@ def _make_task_hook(card, done_event, on_final): return hook +class FeishuApp(AgentChatMixin): + label, source, split_limit = "Feishu", "feishu", 4000 + + async def send_text(self, chat_id, content, *, receive_id=None, receive_id_type="open_id", **_): + rid = receive_id or chat_id + for part in split_text(content, self.split_limit): + await asyncio.to_thread(send_message, rid, part, "text", False, receive_id_type) + + async def send_done(self, chat_id, raw_text, *, receive_id=None, receive_id_type="open_id", **_): + rid = receive_id or chat_id + text = _display_text(raw_text) + await asyncio.to_thread(send_message, rid, text, "text", False, receive_id_type) + await asyncio.to_thread(_send_generated_files, rid, raw_text, receive_id_type) + + async def run_agent(self, chat_id, text, *, receive_id=None, receive_id_type="open_id", images=None, **_): + if self.user_tasks: + await self.send_text(chat_id, "当前会话已有任务在运行,请等待完成或发送 /stop 后再试。", receive_id=receive_id, receive_id_type=receive_id_type) + return + state = {"running": True} + self.user_tasks[chat_id] = state + rid = receive_id or chat_id + task_id = f"{chat_id}_{uuid.uuid4().hex}" + hook_key = f"fs_{task_id}" + card = _TaskCard(rid, receive_id_type) + result = {"raw": None, "sent": False} + finish_lock = threading.Lock() + + def _finish(raw): + with finish_lock: + if result["sent"]: + return + result["raw"] = raw + result["sent"] = True + card.done(_display_text(raw)) + _send_generated_files(rid, raw, receive_id_type=receive_id_type) + + try: + await asyncio.to_thread(card.start) + if not hasattr(self.agent, '_turn_end_hooks'): + self.agent._turn_end_hooks = {} + self.agent._turn_end_hooks[hook_key] = _make_task_hook(card, task_id, _finish) + self.agent._fs_active_task_id = task_id + dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source, images=images or None) + start = time.time() + while state["running"] and not result["sent"]: + try: + item = await asyncio.to_thread(dq.get, True, 1) + except Q.Empty: + item = None + if item and "done" in item: + await asyncio.to_thread(_finish, item.get("done", "")) + break + if time.time() - start > AGENT_TIMEOUT_SEC: + self.agent.abort() + await asyncio.to_thread(card.fail, "任务超时") + break + if not state["running"] and not result["sent"]: + self.agent.abort() + await asyncio.to_thread(card.fail, "已停止") + except Exception as e: + traceback.print_exc() + await asyncio.to_thread(card.fail, f"错误: {e}") + finally: + if getattr(self.agent, "_fs_active_task_id", None) == task_id: + try: + delattr(self.agent, "_fs_active_task_id") + except AttributeError: + pass + if hasattr(self.agent, '_turn_end_hooks'): + self.agent._turn_end_hooks.pop(hook_key, None) + self.user_tasks.pop(chat_id, None) + + +def get_app(): + global app + if app is None: + app = FeishuApp(get_agent(), user_tasks) + return app + + +def _run_async(coro): + try: + asyncio.run(coro) + except Exception: + traceback.print_exc() + + def handle_message(data): event, message, sender = data.event, data.event.message, data.event.sender - msg_id = getattr(message, "message_id", "") or "" - if msg_id and not _message_claim_once(msg_id): - print(f"[INFO] duplicate Feishu message ignored: {msg_id}") + message_id = getattr(message, "message_id", "") or "" + if not _claim_message_once(message_id): + print(f"忽略重复飞书消息: {message_id}") return open_id = sender.sender_id.open_id chat_id = message.chat_id @@ -708,133 +827,54 @@ def handle_message(data): send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}") return print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}") + receive_id = chat_id or open_id + receive_id_type = "chat_id" if chat_id else "open_id" + chat_key = receive_id if message.message_type == "text" and user_input.startswith("/"): - return handle_command(open_id, user_input, chat_id) - - def run_agent(): - user_tasks[open_id] = {"running": True} - receive_id = chat_id or open_id - rid_type = "chat_id" if chat_id else "open_id" - done_event = threading.Event() - hook_key = f"fs_{open_id}" - card = _TaskCard(receive_id, rid_type) - card.start() - on_final = lambda raw: _send_generated_files(receive_id, raw, receive_id_type=rid_type) - if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} - agent._turn_end_hooks[hook_key] = _make_task_hook(card, done_event, on_final) - try: - # Keep the display_queue as the authoritative completion channel. - # The turn_end_hook updates the rich card per turn, but startup/cold-run - # races or hook errors must not leave Feishu waiting forever. - dq = agent.put_task(user_input, source="feishu", images=image_paths) - start = time.time() - while True: - # Drain any agent output first; this is independent of desktop/Streamlit UI. - try: - while True: - item = dq.get_nowait() - if 'done' in item: - raw = item.get('done', '') - if not done_event.is_set(): - card.done(_display_text(raw)) - on_final(raw) - done_event.set() - break - except Q.Empty: - pass - if done_event.is_set(): - break - if not user_tasks.get(open_id, {}).get("running", True): - agent.abort() - card.fail("已停止") - break - if time.time() - start > AGENT_TIMEOUT_SEC: - agent.abort() - card.fail("任务超时") - break - done_event.wait(timeout=0.5) - except Exception as e: - traceback.print_exc() - card.fail(f"错误: {e}") - finally: - agent._turn_end_hooks.pop(hook_key, None) - user_tasks.pop(open_id, None) - - threading.Thread(target=run_agent, daemon=True).start() - - -def handle_command(open_id, cmd, chat_id=None): - def _send_cmd_response(content): - if chat_id: - send_message(chat_id, content, receive_id_type="chat_id") - else: - send_message(open_id, content) - parts = (cmd or "").split() - op = (parts[0] if parts else "").lower() - if op == "/stop": - if open_id in user_tasks: - user_tasks[open_id]["running"] = False - agent.abort() - _send_cmd_response("正在停止...") - elif op == "/new": - _send_cmd_response(reset_conversation(agent)) - elif op == "/help": - _send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/llm - 查看当前模型列表\n/llm [n] - 切换到第 n 个模型\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/help - 显示帮助") - elif op == "/status": - llm = agent.get_llm_name() if agent.llmclient else "未配置" - _send_cmd_response(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}") - elif op == "/llm": - if not agent.llmclient: - return _send_cmd_response("❌ 当前没有可用的 LLM 配置") - if len(parts) > 1: - try: - agent.next_llm(int(parts[1])) - return _send_cmd_response(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}") - except Exception: - return _send_cmd_response(f"用法: /llm <0-{len(agent.list_llms()) - 1}>") - lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] - _send_cmd_response("LLMs:\n" + "\n".join(lines)) - elif op == "/restore": - try: - restored_info, err = format_restore() - if err: - return _send_cmd_response(err.replace("❌ ", "")) - restored, fname, count = restored_info - agent.history.extend(restored) - agent.abort() - _send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") - except Exception as e: - _send_cmd_response(f"恢复失败: {e}") - elif op == "/continue" or cmd.startswith("/continue"): - _send_cmd_response(handle_continue_frontend(agent, cmd)) - else: - _send_cmd_response(f"未知命令: {cmd}") + threading.Thread( + target=_run_async, + args=(get_app().handle_command(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type),), + daemon=True, + ).start() + return + threading.Thread( + target=_run_async, + args=(get_app().run_agent(chat_key, user_input, receive_id=receive_id, receive_id_type=receive_id_type, images=image_paths),), + daemon=True, + ).start() def main(): - global client + global client, APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH + APP_ID, APP_SECRET, ALLOWED_USERS, PUBLIC_ACCESS, CONFIG_PATH = _feishu_config() if not APP_ID or not APP_SECRET: - print("错误: 请在 mykey.py 或 mykey.json 中配置 fs_app_id 和 fs_app_secret") + print(f"错误: 请在 mykey 配置中填写 fs_app_id 和 fs_app_secret\n配置文件: {CONFIG_PATH}", flush=True) sys.exit(1) - client = create_client() handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build() - print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50) retry_delay = 5 while True: try: + client = create_client() cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO) + print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n配置: {CONFIG_PATH}\n等待消息...\n" + "=" * 50, flush=True) cli.start() + retry_delay = 5 + except KeyboardInterrupt: + raise except Exception as e: - print(f"[WARN] 飞书长连接断开或启动失败: {e}") - print(f"[INFO] {retry_delay}s 后重连...") + print(f"[WARN] 飞书长连接断开或启动失败: {e}", flush=True) + traceback.print_exc() + print(f"[INFO] {retry_delay}s 后重连飞书长连接...", flush=True) time.sleep(retry_delay) retry_delay = min(retry_delay * 2, 120) - # 重连时刷新 client - try: - client = create_client() - except Exception: - pass if __name__ == "__main__": - main() + parser = argparse.ArgumentParser(description="A3Agent Feishu frontend") + parser.add_argument("--check", action="store_true", help="只检查飞书配置,不启动长连接") + parser.add_argument("--check-agent", action="store_true", help="检查配置并初始化 Agent/LLM") + args = parser.parse_args() + if args.check or args.check_agent: + print(json.dumps(check_config(init_agent=args.check_agent), ensure_ascii=False, indent=2), flush=True) + else: + main() diff --git a/frontends/qqapp.py b/frontends/qqapp.py index 30a66414..2ad088f8 100644 --- a/frontends/qqapp.py +++ b/frontends/qqapp.py @@ -75,7 +75,11 @@ class QQApp(AgentChatMixin): api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message key = "group_openid" if is_group else "openid" for part in split_text(content, self.split_limit): - await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": _next_msg_seq()}) + seq = _next_msg_seq() + try: + await api(**{key: chat_id, "msg_type": 2, "markdown": {"content": part}, "msg_id": msg_id, "msg_seq": seq}) + except Exception: + await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": seq}) async def on_message(self, data, is_group=False): try: diff --git a/frontends/stapp.py b/frontends/stapp.py index 0b5e2639..29b8951e 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -94,19 +94,23 @@ def render_sidebar(): st.toast("Desktop pet started") if LANG == 'zh': + if st.button('🎯 给我找点事做'): + st.session_state['_inject_prompt'] = '按照自主行动的规划部分,充分分析我的情况,给我生成一批TODO,务必让我感兴趣' + st.rerun(scope="app") st.divider() if st.button("开始空闲自主行动"): st.session_state.last_reply_time = int(time.time()) - 1800 - st.toast("已将上次回复时间设为1800秒前"); st.rerun() + st.session_state.autonomous_enabled = True + st.toast("已将上次回复时间设为1800秒前,自主行动已激活"); st.rerun(scope="app") if st.session_state.autonomous_enabled: if st.button("⏸️ 禁止自主行动"): st.session_state.autonomous_enabled = False - st.toast("⏸️ 已禁止自主行动"); st.rerun() + st.toast("⏸️ 已禁止自主行动"); st.rerun(scope="app") st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") else: if st.button("▶️ 允许自主行动", type="primary"): st.session_state.autonomous_enabled = True - st.toast("✅ 已允许自主行动"); st.rerun() + st.toast("✅ 已允许自主行动"); st.rerun(scope="app") st.caption("🔴 自主行动已停止") with st.sidebar: render_sidebar() @@ -151,11 +155,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. @@ -235,14 +235,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 @@ -257,7 +260,9 @@ _js_ime_fix = ("" if os.name == 'nt' else "f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()") _embed_html(f'', height=0) -if prompt := st.chat_input("any task?"): +_injected = st.session_state.pop('_inject_prompt', None) +prompt = st.chat_input("any task?") or _injected +if prompt: ts = time.strftime("%Y-%m-%d %H:%M:%S") cmd = (prompt or "").strip() def _reset_and_rerun(): diff --git a/frontends/tgapp.py b/frontends/tgapp.py index fddab2f5..0716af94 100644 --- a/frontends/tgapp.py +++ b/frontends/tgapp.py @@ -42,11 +42,19 @@ _RETRY_AFTER_MARGIN_SECONDS = 1.0 _QUEUE_WAIT_SECONDS = 1 _ASK_USER_HOOK_KEY = "telegram_ask_user_menu" _ASK_CALLBACK_PREFIX = "ask:" +_LLM_CALLBACK_PREFIX = "llm:" _ASK_CANCEL_ACTION = "none" +_ASK_MULTI_DONE_ACTION = "done" +_ASK_TOGGLE_ACTION = "toggle" _ASK_CANCEL_LABEL = "none of these above" _ASK_CANCEL_PROMPT = "已取消选择,请直接发送下一步操作。" +_ASK_MULTI_HINT = "可多选:点选项目后点击 Done 提交。" +_ASK_MULTI_EMPTY_HINT = "请至少选择一项,或选择 none of these above。" +_LLM_MENU_PROMPT = "请选择要切换的 LLM:" _ask_menu_events = Q.Queue() _ask_menu_store = {} +_llm_menu_store = {} +_MULTI_SELECT_RE = re.compile(r"\[?(?:多选|multi(?:[-_ ]?select)?|select all)\]?", re.IGNORECASE) _QUOTE_OPEN_TAG = "<_quote_>" _QUOTE_CLOSE_TAG = "" _QUOTE_TOKEN_PATTERN = re.escape(_QUOTE_OPEN_TAG) + r"([\s\S]*?)" + re.escape(_QUOTE_CLOSE_TAG) @@ -265,7 +273,11 @@ def _extract_ask_user_event(ctx): if not candidates: return None question = str(data.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:" - return {"question": question, "candidates": candidates} + return { + "question": question, + "candidates": candidates, + "multi": bool(_MULTI_SELECT_RE.search(question)), + } def _register_ask_user_hook(): if not hasattr(agent, "_turn_end_hooks"): @@ -285,25 +297,49 @@ def _drain_latest_ask_user_event(): break return latest -def _build_ask_user_markup(menu_id, candidates): - rows = [ - [InlineKeyboardButton(candidate, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{idx}")] - for idx, candidate in enumerate(candidates) - ] +def _build_ask_user_markup(menu_id, candidates, multi=False, selected_indexes=None): + selected_indexes = set(selected_indexes or []) + rows = [] + for idx, candidate in enumerate(candidates): + if multi: + label = f"✓ {candidate}" if idx in selected_indexes else candidate + action = f"{_ASK_TOGGLE_ACTION}:{idx}" + else: + label = candidate + action = str(idx) + rows.append([ + InlineKeyboardButton(label, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{action}") + ]) + if multi: + rows.append([ + InlineKeyboardButton("Done", callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_MULTI_DONE_ACTION}") + ]) rows.append([ InlineKeyboardButton(_ASK_CANCEL_LABEL, callback_data=f"{_ASK_CALLBACK_PREFIX}{menu_id}:{_ASK_CANCEL_ACTION}") ]) return InlineKeyboardMarkup(rows) -def _parse_ask_callback_data(data): - if not (data or "").startswith(_ASK_CALLBACK_PREFIX): +def _build_llm_markup(menu_id, llms): + rows = [] + for idx, name, current in llms: + label = f"→ [{idx}] {name}" if current else f"[{idx}] {name}" + rows.append([ + InlineKeyboardButton(label, callback_data=f"{_LLM_CALLBACK_PREFIX}{menu_id}:{idx}") + ]) + return InlineKeyboardMarkup(rows) + +def _parse_menu_callback_data(data, prefix): + if not (data or "").startswith(prefix): return None, None - payload = data[len(_ASK_CALLBACK_PREFIX):] + payload = data[len(prefix):] menu_id, sep, action = payload.partition(":") if not sep or not menu_id or not action: return None, None return menu_id, action +def _parse_ask_callback_data(data): + return _parse_menu_callback_data(data, _ASK_CALLBACK_PREFIX) + def _build_text_prompt(text): return f"{FILE_HINT}\n\n{text}" @@ -313,11 +349,15 @@ def _normalize_ask_menu_event(stored): return { "question": str(stored.get("question") or "请选择下一步操作:").strip() or "请选择下一步操作:", "candidates": [str(candidate).strip() for candidate in candidates if str(candidate).strip()], + "multi": bool(stored.get("multi")), + "selected": [int(idx) for idx in stored.get("selected", []) if isinstance(idx, int)], } if isinstance(stored, (list, tuple)): return { "question": "请选择下一步操作:", "candidates": [str(candidate).strip() for candidate in stored if str(candidate).strip()], + "multi": False, + "selected": [], } return None @@ -357,11 +397,18 @@ async def _edit_ask_user_result(query, event, selected=None, cancelled=False): async def _send_ask_user_menu(root_msg, event): menu_id = uuid.uuid4().hex[:16] candidates = event["candidates"] - _ask_menu_store[menu_id] = {"question": event["question"], "candidates": list(candidates)} + multi = bool(event.get("multi")) + _ask_menu_store[menu_id] = { + "question": event["question"], + "candidates": list(candidates), + "multi": multi, + "selected": [], + } + prompt = f"{event['question']}\n\n{_ASK_MULTI_HINT}" if multi else event["question"] try: await root_msg.reply_text( - event["question"], - reply_markup=_build_ask_user_markup(menu_id, candidates), + prompt, + reply_markup=_build_ask_user_markup(menu_id, candidates, multi=multi), ) except Exception as exc: _ask_menu_store.pop(menu_id, None) @@ -845,6 +892,45 @@ async def handle_ask_callback(update, ctx): await query.answer("菜单已过期") return await _clear_ask_reply_markup(query) candidates = event["candidates"] + if event.get("multi") and action.startswith(f"{_ASK_TOGGLE_ACTION}:"): + try: + selected_idx = int(action.split(":", 1)[1]) + if selected_idx < 0 or selected_idx >= len(candidates): + raise ValueError + except ValueError: + return await query.answer("菜单无效") + stored = _ask_menu_store.get(menu_id) + if not isinstance(stored, dict): + return await query.answer("菜单已过期") + selected = set(stored.get("selected", [])) + if selected_idx in selected: + selected.remove(selected_idx) + else: + selected.add(selected_idx) + stored["selected"] = sorted(selected) + await query.answer() + return await query.edit_message_reply_markup( + reply_markup=_build_ask_user_markup( + menu_id, + candidates, + multi=True, + selected_indexes=stored["selected"], + ) + ) + if event.get("multi") and action == _ASK_MULTI_DONE_ACTION: + selected_indexes = event.get("selected") or [] + if not selected_indexes: + return await query.answer(_ASK_MULTI_EMPTY_HINT, show_alert=True) + selected = "; ".join(candidates[idx] for idx in selected_indexes) + _ask_menu_store.pop(menu_id, None) + await query.answer() + await _edit_ask_user_result(query, event, selected=selected) + if query.message is None: + return + dq = agent.put_task(_build_text_prompt(selected), source="telegram") + task = asyncio.create_task(_stream(dq, query.message)) + ctx.user_data['stream_task'] = task + return if action == _ASK_CANCEL_ACTION: _ask_menu_store.pop(menu_id, None) await query.answer() @@ -865,6 +951,52 @@ async def handle_ask_callback(update, ctx): task = asyncio.create_task(_stream(dq, query.message)) ctx.user_data['stream_task'] = task +async def _send_llm_menu(message): + llms = agent.list_llms() + if not llms: + return await message.reply_text("没有可用模型。") + menu_id = uuid.uuid4().hex[:16] + _llm_menu_store[menu_id] = [idx for idx, _, _ in llms] + lines = [f"{'→' if cur else ' '} [{idx}] {name}" for idx, name, cur in llms] + try: + await message.reply_text( + _LLM_MENU_PROMPT, + reply_markup=_build_llm_markup(menu_id, llms), + ) + except Exception as exc: + _llm_menu_store.pop(menu_id, None) + print(f"[TG llm menu error] {type(exc).__name__}: {exc}", flush=True) + await message.reply_text("LLMs:\n" + "\n".join(lines)) + +async def handle_llm_callback(update, ctx): + query = update.callback_query + if query is None: + return + uid = update.effective_user.id if update.effective_user else None + if ALLOWED and uid not in ALLOWED: + return await query.answer("no", show_alert=True) + menu_id, action = _parse_menu_callback_data(query.data, _LLM_CALLBACK_PREFIX) + if not menu_id: + return await query.answer("菜单无效") + valid_indexes = _llm_menu_store.get(menu_id) + if valid_indexes is None: + await query.answer("菜单已过期") + return await _clear_ask_reply_markup(query) + try: + selected_idx = int(action) + except (TypeError, ValueError): + return await query.answer("菜单无效") + if selected_idx not in valid_indexes: + return await query.answer("菜单已过期", show_alert=True) + try: + agent.next_llm(selected_idx) + selected_name = agent.get_llm_name() + except Exception as exc: + return await query.answer(f"切换失败: {exc}", show_alert=True) + _llm_menu_store.pop(menu_id, None) + await query.answer(f"已切换到 [{selected_idx}] {selected_name}") + await query.edit_message_text(f"✅ 已切换到 [{selected_idx}] {selected_name}") + async def cmd_abort(update, ctx): _cancel_stream_task(ctx) agent.abort() @@ -880,8 +1012,7 @@ async def cmd_llm(update, ctx): except (ValueError, IndexError): await update.message.reply_text(f"用法: /llm <0-{len(agent.list_llms())-1}>") else: - lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] - await update.message.reply_text("LLMs:\n" + "\n".join(lines)) + await _send_llm_menu(update.message) async def handle_photo(update, ctx): uid = update.effective_user.id @@ -969,6 +1100,7 @@ if __name__ == '__main__': app = (ApplicationBuilder().token(mykeys['tg_bot_token']) .request(request).get_updates_request(request).post_init(_sync_commands).build()) app.add_handler(CallbackQueryHandler(handle_ask_callback, pattern=r"^ask:")) + app.add_handler(CallbackQueryHandler(handle_llm_callback, pattern=r"^llm:")) app.add_handler(MessageHandler(filters.COMMAND, handle_command)) app.add_handler(MessageHandler(filters.PHOTO, handle_photo)) app.add_handler(MessageHandler(filters.Document.ALL, handle_photo)) diff --git a/frontends/tui_v3.py b/frontends/tui_v3.py new file mode 100644 index 00000000..8b011ec9 --- /dev/null +++ b/frontends/tui_v3.py @@ -0,0 +1,4609 @@ +"""tui_v3 — scrollback-first TUI for GenericAgent, consolidated. + +Merged from frontends/tui/ (cjk, clipboard, renderer, protocol, core/sb) +into a single file so the v3 frontend ships as one drop-in module. +Run: `python -m frontends.tui_v3` or `python frontends/tui_v3.py`. +""" +from __future__ import annotations + +import asyncio, atexit, json, locale, logging, os, queue, random, re, select, shutil, signal, subprocess +import sys, tempfile, threading, time + +_IS_WINDOWS = os.name == 'nt' + + +# Make `frontends/` parent (project root) importable so `from agentmain import …` +# works whether this file is run as `python -m frontends.tui_v3` or directly +# via `python frontends/tui_v3.py`. +_proj_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_front_dir = os.path.dirname(os.path.abspath(__file__)) +for _p in (_proj_root, _front_dir): + if _p not in sys.path: + sys.path.insert(0, _p) + +from agentmain import GeneraticAgent +from dataclasses import dataclass +from dataclasses import dataclass, field +from functools import lru_cache +from io import StringIO +from rich.cells import cell_len +from rich.console import Console +from rich.markdown import Markdown +from rich.text import Text +from rich.theme import Theme +from typing import Callable + +# ════════════════════════════════════════════════════════════════════════════ +# i18n — minimal dict-based zh/en translation layer (inlined; was tui_v3_i18n.py) +# +# - Single nested dict `_I18N[lang][key]` → format string. +# - `t(key, **fmt)` returns the formatted string for the current language; +# falls back to English, then to the key itself (so missing keys are visible). +# - Language detection: persisted user choice > system locale > 'en'. +# - Persistence: temp/tui_v3_settings.json (workspace-local, matches v2 pattern). +# +# Strings that intentionally stay single-language (English): +# - Spinner gerunds (Pondering/Brewing/...) — ported from v2. +# - Tech jargon embedded in zh strings: 'tokens', 'ctx', 'model', 'session', … +# ════════════════════════════════════════════════════════════════════════════ + +_SETTINGS_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "temp", "tui_v3_settings.json" +) + +_SUPPORTED = ('zh', 'en') +_DEFAULT = 'en' + +_LANG: str = _DEFAULT + + +# ---------------- spinner gerunds (English only, from v2) ---------------- + +SPINNER_GERUNDS = ( + "Pondering", "Reticulating", "Sleuthing", "Hatching", "Pouncing", + "Brewing", "Sharpening", "Untangling", "Compiling", "Unraveling", + "Distilling", "Calibrating", "Marinating", "Conjuring", "Foraging", + "Spelunking", "Synthesizing", "Refactoring thoughts", "Tracing breadcrumbs", + "Following the rabbit hole", + "Routing", "Threading", "Polling", "Spinning", "Hooking", + "Patching", "Caching", "Yielding", "Hydrating", "Folding", + "Streaming", "Resolving", "Reaping", "Tuning", +) + + +# Language display names (always shown in their own script — never translated, +# so users always see them in a form they recognize). +LANG_LABELS = { + 'zh': '简体中文', + 'en': 'English', +} + + +# Rotating usage tips — one picked per launch, shown in the banner (v2 _TIPS). +# Only covers features v3 has actually adapted; the en/zh lists run parallel. +_TIPS = { + 'en': [ + "Tip: press / to open the command palette — arrow keys to pick, Enter drops it into the input box.", + "Tip: pasted images / files fold into [Image #N] / [File #N] placeholders; backspace deletes the whole block.", + "Tip: /btw lets a side-agent answer without interrupting the main task.", + "Tip: /rewind [n] rewinds the last n turns; /stop aborts the current task.", + "Tip: /continue lists past sessions — arrow keys to pick, Enter to restore.", + "Tip: Ctrl+J / Shift+Enter inserts a newline in multi-line input; Enter sends.", + "Tip: put [multi-select] in an ask_user prompt to switch to a multi-pick picker.", + "Tip: /cost shows token usage; /llm views / switches the model.", + "Tip: /new [name] starts a fresh session; /language switches the interface language.", + "Tip: /export clip copies the last reply to your system clipboard; /export all prints the log path.", + "Tip: Ctrl+O folds / unfolds all completed tool chips — each fold collapses to one line.", + ], + 'zh': [ + "Tip: 按 / 唤起命令面板 —— 方向键选择,Enter 落入输入框。", + "Tip: 粘贴图片 / 文件会折叠成 [Image #N] / [File #N] 占位符,退格可整块删除。", + "Tip: /btw <问题> 让 side-agent 回答而不打断主任务。", + "Tip: /rewind [n] 回退最近 n 轮对话;/stop 中止当前任务。", + "Tip: /continue 列出历史会话 —— 方向键选择,Enter 恢复。", + "Tip: 多行输入用 Ctrl+J / Shift+Enter 换行;Enter 直接发送。", + "Tip: ask_user 题目里写 [多选] 会自动切到多选 picker。", + "Tip: /cost 查看 token 用量;/llm 查看 / 切换模型。", + "Tip: /new [name] 新建会话;/language 切换界面语言。", + "Tip: /export clip 把最后回复复制到系统剪贴板;/export all 打印日志路径。", + "Tip: Ctrl+O 折叠 / 展开所有已完成的工具 chip —— 每个 chip 折叠成一行。", + ], +} + + +# ---------------- translations ---------------- +# Keys are dot-namespaced. Format placeholders use {name}. + +_I18N: dict[str, dict[str, str]] = { + 'en': { + # /help intro & rows — phrasing mirrors v2's COMMANDS table. + 'help.title': 'Commands:', + 'help.help': ' /help Show help', + 'help.status': ' /status View session status', + 'help.sessions': ' /sessions List all sessions', + 'help.llm': ' /llm [n] View / switch model', + 'help.btw': ' /btw Side question — does not interrupt main agent', + 'help.review': ' /review [request] In-session code review (report inline)', + 'help.rewind': ' /rewind [n] Rewind the last n rounds', + 'help.continue': ' /continue [n|name] List / restore historical sessions', + 'help.new': ' /new [name] Start a new session (clears the current one)', + 'help.rename': ' /rename Rename the current session', + 'help.clear': ' /clear Clear display (does not touch LLM history)', + 'help.cost': ' /cost Token usage for the current session', + 'help.verbose': ' /verbose Tool-call audit (↑↓ select · Enter switch · c copy · q quit)', + 'help.export': ' /export [sub] Export last reply: clip / file [name] / all', + 'help.stop': ' /stop Abort current task', + 'help.language': ' /language [code] View / switch interface language', + 'help.quit': ' /quit Quit', + 'help.esc': ' Esc Cancel ask · clear draft · stop task (no exit)', + 'help.cc': ' Ctrl+C × 2 Quit (when idle; only aborts the task while running)', + 'help.cl': ' Ctrl+L Force repaint (recover from sleep/wake)', + 'help.cz': ' Ctrl+Z / Ctrl+Y Undo / redo input edits', + 'help.shift_arrow': ' Shift+←→↑↓ Select text (Ctrl+C copy / Ctrl+X cut / Ctrl+A all)', + + # _CMDS palette entries — same wording as /help, condensed for one-line hint. + 'cmd.help.desc': 'Show help', + 'cmd.status.desc': 'View session status', + 'cmd.llm.desc': 'View / switch model', + 'cmd.btw.desc': 'Side question — does not interrupt main agent', + 'cmd.review.desc': 'In-session code review', + 'cmd.rewind.desc': 'Rewind the last n rounds', + 'cmd.continue.desc': 'List / restore historical sessions', + 'cmd.new.desc': 'Start a new session', + 'cmd.rename.desc': 'Rename the current session', + 'cmd.clear.desc': 'Clear display (LLM history untouched)', + 'cmd.cost.desc': 'Token usage for the current session', + 'cmd.verbose.desc': 'Tool-call audit', + 'cmd.export.desc': 'Export the last reply (clip/file/all)', + 'cmd.stop.desc': 'Abort current task', + 'cmd.language.desc': 'View / switch interface language', + 'cmd.quit.desc': 'Quit', + + # _CMDS arg hints — mirror v2 (lowercase n, full word "question"). + 'cmd.llm.arg': '[n]', + 'cmd.btw.arg': '', + 'cmd.review.arg': '[request]', + 'cmd.rewind.arg': '[n]', + 'cmd.continue.arg': '[n|name]', + 'cmd.new.arg': '[name]', + 'cmd.rename.arg': '', + 'cmd.export.arg': '[clip|file|all]', + 'cmd.language.arg': '[code]', + + # status line (one-liner above input box) + 'status.asking': '◉ waiting · Esc cancel', + 'status.running.tail': ' · Esc stop', + 'status.tps': ' · {rate:.0f} tok/s', + 'status.cc_confirm': 'Press Ctrl+C again to quit', + 'status.ready': '○ ready', + + # /status output rows + 'status.title': ' Session status', + 'status.label.model': 'model:', + 'status.label.state': 'state:', + 'status.label.rounds': 'rounds:', + 'status.label.context': 'context:', + 'status.label.cwd': 'cwd:', + 'status.state.running': 'running · {verb} {elapsed}', + 'status.state.waiting': 'waiting · ask_user pending', + 'status.state.idle': 'idle', + 'status.ctx.unknown': 'n/a', + 'status.ctx.fmt': '{used:,} / {cap:,} ctx', + + # banner + 'banner.label.model': 'model:', + 'banner.label.directory': 'directory:', + 'banner.label.session': 'session:', + 'banner.session.single': 'single · scrollback', + 'banner.llm_hint': '/llm switch', + + # messages — success / status + 'msg.ask_cancelled': '✗ ask cancelled · type freely or ask again', + 'msg.abort_requested': '⏹ abort requested · Esc', + 'msg.abort_done': '⏹ abort requested', + 'msg.idle_no_task': '(idle, no task)', + 'msg.cleared': 'new conversation · context cleared', + 'msg.new_session': 'new session · previous conversation cleared', + 'msg.new_session_named': 'new session "{name}" · previous conversation cleared', + 'msg.renamed': '✎ session renamed to "{name}"', + 'msg.rewind': '↩ rewound {n} turn(s) (removed {removed} history entries; scrollback is not editable)', + 'msg.no_rewindable': 'no rewindable turns', + 'msg.continue_loading': '┄┄ loaded {name}, full context above ┄┄', + 'msg.continue_ready': '┄┄ {msg} · continue typing ┄┄', + 'msg.llm_switched': 'LLM → {name}', + 'msg.export_done': 'exported: {path}', + 'msg.export_clipped': 'copied to clipboard ({n} chars)', + 'msg.export_clip_failed': '❌ copy failed: no clipboard tool found', + 'msg.export_all': 'full log:\n{path}', + 'msg.export_all_missing': 'no log file yet', + 'msg.review_empty': '(review produced no output)', + 'msg.no_export': '(no reply to export)', + 'msg.no_tracker': '(no stats yet)', + 'msg.no_history': ' no restorable historical sessions', + 'msg.no_llms': '(no LLMs available)', + 'msg.no_tools': ' (no tool-call records)', + 'msg.lang_current': 'Current language: {label} ({code})', + 'msg.lang_switched': 'Language → {label}', + 'msg.btw_no_answer': '(no answer)', + 'btw.title': 'side-questions · Esc to clear', + 'btw.querying': ' ⋯ querying…', + + # plan card + 'plan.header': 'Plan ({done}/{total})', + 'plan.complete': '✓ Plan complete ({n}/{n})', + 'plan.placeholder': 'Plan mode activated', + 'plan.waiting': 'waiting for {path} …', + 'plan.overflow': '+{n} more', + + # errors + 'err.running_blocked': 'busy — /stop before using this command', + 'err.continue_usage': 'usage: /continue or /continue N', + 'err.index_oob': '❌ index out of range (valid: 1-{max})', + 'err.btw_usage': 'usage: /btw (does not pollute main context)', + 'err.rewind_usage': 'usage: /rewind ', + 'err.rename_usage': 'usage: /rename ', + 'err.rewind_range': '❌ rewind failed: n must be 1-{max}', + 'err.lang_usage': 'usage: /language [code] (codes: {available})', + 'err.lang_unknown': 'unknown language code: {code} (available: {available})', + 'err.unknown_cmd': 'unknown command /{name} — /help to list', + 'err.multi_session': '/{name}: multi-session backend not wired yet; command reserved', + 'err.menu_cb': '❌ menu callback failed: {err}', + 'err.no_llm': 'No LLM configured — check mykey.py', + 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)', + 'err.dep_missing': 'Error: {name} is not installed.', + 'err.dep_install': 'Install with: pip install rich prompt_toolkit', + + # menu / picker / palette + 'menu.default_title': 'Pick', + 'menu.hint': '↑↓ pick · Enter confirm · Esc cancel', + 'ask.default_q': 'answer:', + 'ask.title': '◉ answer', + 'ask.pending': ' +{n} pending', + 'ask.hint.multi': '↳ ↑↓ move · Space toggle · Enter submit · Esc cancel', + 'ask.hint.single': '↳ ↑↓ navigate (options ⇄ input) · Enter confirm · Esc cancel', + 'ask.hint.freetext': '↳ ↑↓ back to options · type to input · Enter submit · Esc cancel', + + # continue picker + 'continue.title': 'Restore historical session', + 'continue.row.fmt': '{rel:>4} {rounds:>3}r {preview}', + 'continue.unit.round': 'r', + + # llm picker + 'llm.title': 'Switch LLM', + + # export picker + 'export.title': 'Export the last reply', + 'export.opt.clip': 'Copy to clipboard', + 'export.opt.file': 'Save to file (temp/)', + 'export.opt.all': 'Show full log path', + + # rewind picker + 'rewind.title': 'Rewind to which turn', + 'rewind.option': 'rewind {n} turn(s) · {preview}', + + # language picker + 'lang.title': 'Switch interface language', + + # verbose + 'verbose.title': ' Tool Trace', + 'verbose.hint': ' ↑↓ pick · PgUp/Dn scroll · Enter switch[{field}] · c copy · e export · q quit', + 'verbose.empty': '(empty)', + + # answer prefix when committing user reply to ask_user + 'msg.answer_prefix': '[ans] {text}', + }, + + 'zh': { + # /help intro & rows — 措辞与 v2 COMMANDS 表对齐。 + 'help.title': '命令:', + 'help.help': ' /help 显示帮助', + 'help.status': ' /status 查看会话状态', + 'help.sessions': ' /sessions 列出所有会话', + 'help.llm': ' /llm [n] 查看 / 切换模型', + 'help.btw': ' /btw 旁问 — 不打断主 agent', + 'help.review': ' /review [request] in-session 代码审查(直接输出报告)', + 'help.rewind': ' /rewind [n] 回退最近 n 轮', + 'help.continue': ' /continue [n|name] 列出 / 恢复历史会话', + 'help.new': ' /new [name] 新建会话(清空当前会话)', + 'help.rename': ' /rename 重命名当前会话', + 'help.clear': ' /clear 清空显示(不动 LLM 历史)', + 'help.cost': ' /cost 显示当前会话 token 用量', + 'help.verbose': ' /verbose 工具调用审计(↑↓ 选 · Enter 切换 · c 复制 · q 退)', + 'help.export': ' /export [sub] 导出最后回复:clip / file [name] / all', + 'help.stop': ' /stop 中止当前任务', + 'help.language': ' /language [code] 查看 / 切换界面语言', + 'help.quit': ' /quit 退出', + 'help.esc': ' Esc 撤回提问 · 清草稿 · 停任务(不退出)', + 'help.cc': ' Ctrl+C × 2 退出(空闲时;运行中只 abort 任务)', + 'help.cl': ' Ctrl+L 强制重画(睡眠唤醒后修复)', + 'help.cz': ' Ctrl+Z / Ctrl+Y 撤销 / 重做 输入框编辑', + 'help.shift_arrow': ' Shift+←→↑↓ 选中文字(Ctrl+C 复制 / Ctrl+X 剪切 / Ctrl+A 全选)', + + # _CMDS palette entries — 与 /help 同源,命令面板单行显示。 + 'cmd.help.desc': '显示帮助', + 'cmd.status.desc': '查看会话状态', + 'cmd.llm.desc': '查看 / 切换模型', + 'cmd.btw.desc': '旁问 — 不打断主 agent', + 'cmd.review.desc': 'in-session 代码审查', + 'cmd.rewind.desc': '回退最近 n 轮', + 'cmd.continue.desc': '列出 / 恢复历史会话', + 'cmd.new.desc': '新建会话', + 'cmd.rename.desc': '重命名当前会话', + 'cmd.clear.desc': '清空显示(不动 LLM 历史)', + 'cmd.cost.desc': '显示当前会话 token 用量', + 'cmd.verbose.desc': '工具调用审计', + 'cmd.export.desc': '导出最后回复(剪贴板/文件/日志路径)', + 'cmd.stop.desc': '中止当前任务', + 'cmd.language.desc': '查看 / 切换界面语言', + 'cmd.quit.desc': '退出', + + # arg hints — 与 v2 对齐:小写 n、完整的 question 等。 + 'cmd.llm.arg': '[n]', + 'cmd.btw.arg': '', + 'cmd.review.arg': '[request]', + 'cmd.rewind.arg': '[n]', + 'cmd.continue.arg': '[n|name]', + 'cmd.new.arg': '[name]', + 'cmd.rename.arg': '', + 'cmd.export.arg': '[clip|file|all]', + 'cmd.language.arg': '[code]', + + # status line + 'status.asking': '◉ 待答 · Esc 撤回提问', + 'status.running.tail': ' · Esc 停', + 'status.tps': ' · {rate:.0f} tok/s', + 'status.cc_confirm': '再按 Ctrl+C 退出', + 'status.ready': '○ 就绪', + + # /status + 'status.title': ' 会话状态', + 'status.label.model': 'model:', + 'status.label.state': 'state:', + 'status.label.rounds': 'rounds:', + 'status.label.context': 'context:', + 'status.label.cwd': 'cwd:', + 'status.state.running': 'running · {verb} {elapsed}', + 'status.state.waiting': 'waiting · ask_user pending', + 'status.state.idle': 'idle', + 'status.ctx.unknown': 'n/a', + 'status.ctx.fmt': '{used:,} / {cap:,} ctx', + + # banner + 'banner.label.model': 'model:', + 'banner.label.directory': 'directory:', + 'banner.label.session': 'session:', + 'banner.session.single': '单会话 · scrollback', + 'banner.llm_hint': '/llm 切换', + + # messages + 'msg.ask_cancelled': '✗ 已撤回提问 · 可直接输入或重新发问', + 'msg.abort_requested': '⏹ 已请求中止 · Esc', + 'msg.abort_done': '⏹ 已请求中止', + 'msg.idle_no_task': '(空闲,无任务)', + 'msg.cleared': '新对话 · 上下文已清空', + 'msg.new_session': '新会话 · 上一段对话已清空', + 'msg.new_session_named': '新会话「{name}」· 上一段对话已清空', + 'msg.renamed': '✎ 会话已重命名为「{name}」', + 'msg.rewind': '↩ 回退 {n} 轮(移除 {removed} 条历史;scrollback 不可改,以此为界)', + 'msg.no_rewindable': '没有可回退的轮次', + 'msg.continue_loading': '┄┄ 载入 {name},以下为完整上文 ┄┄', + 'msg.continue_ready': '┄┄ {msg} · 接着说即可 ┄┄', + 'msg.llm_switched': 'LLM → {name}', + 'msg.export_done': '已导出: {path}', + 'msg.export_clipped': '已复制到剪贴板({n} 字符)', + 'msg.export_clip_failed': '❌ 复制失败:未找到剪贴板工具', + 'msg.export_all': '完整日志:\n{path}', + 'msg.export_all_missing': '尚无日志文件', + 'msg.review_empty': '(review 无输出)', + 'msg.no_export': '(没有可导出的回答)', + 'msg.no_tracker': '(暂无统计)', + 'msg.no_history': ' 没有可恢复的历史会话', + 'msg.no_llms': '(无可用 LLM)', + 'msg.no_tools': ' (暂无工具调用记录)', + 'msg.lang_current': '当前界面语言:{label}({code})', + 'msg.lang_switched': '界面语言 → {label}', + 'msg.btw_no_answer': '(无回答)', + 'btw.title': '旁问 · Esc 清空', + 'btw.querying': ' ⋯ 查询中…', + + # plan card + 'plan.header': '计划 ({done}/{total})', + 'plan.complete': '✓ 计划完成 ({n}/{n})', + 'plan.placeholder': '计划模式已激活', + 'plan.waiting': '等待写入 {path} …', + 'plan.overflow': '还有 {n} 项', + + # errors + 'err.running_blocked': '运行中,先 /stop 再用该命令', + 'err.continue_usage': '用法: /continue 或 /continue N', + 'err.index_oob': '❌ 索引越界(有效 1-{max})', + 'err.btw_usage': '用法: /btw <旁问>(不污染主上下文)', + 'err.rewind_usage': '用法:/rewind ', + 'err.rename_usage': '用法:/rename ', + 'err.rewind_range': '❌ 回退失败:n 应在 1-{max}', + 'err.lang_usage': '用法:/language [code] (可选 code:{available})', + 'err.lang_unknown': '未知语言代码:{code} (可选:{available})', + 'err.unknown_cmd': '未知命令 /{name} — /help 看可用命令', + 'err.multi_session': '/{name}:多会话后端尚未接入,命令已预留但暂未实现', + 'err.menu_cb': '❌ 菜单回调失败: {err}', + 'err.no_llm': 'No LLM configured — check mykey.py', + 'err.no_tty': 'tui_v3: needs a real TTY (run it in iTerm directly)', + 'err.dep_missing': 'Error: {name} is not installed.', + 'err.dep_install': 'Install with: pip install rich prompt_toolkit', + + # menu / picker / palette + 'menu.default_title': '选择', + 'menu.hint': '↑↓ 选 · Enter 确认 · Esc 取消', + 'ask.default_q': '请回答:', + 'ask.title': '◉ 请回答', + 'ask.pending': ' +{n} 待答', + 'ask.hint.multi': '↳ ↑↓ 移动 · Space 标记 · Enter 提交 · Esc 撤回', + 'ask.hint.single': '↳ ↑↓ 切换(选项 ⇄ 输入框)· Enter 确认 · Esc 撤回', + 'ask.hint.freetext': '↳ ↑↓ 回到选项 · 输字符输入 · Enter 提交 · Esc 撤回', + + # continue picker + 'continue.title': '恢复历史会话', + 'continue.row.fmt': '{rel:>4} {rounds:>3}轮 {preview}', + 'continue.unit.round': '轮', + + # llm picker + 'llm.title': '切换 LLM', + + # export picker + 'export.title': '导出最后回复', + 'export.opt.clip': '复制到剪贴板', + 'export.opt.file': '保存到文件(temp/)', + 'export.opt.all': '显示完整日志路径', + + # rewind picker + 'rewind.title': '选择回退到的轮次', + 'rewind.option': '回退 {n} 轮 · {preview}', + + # language picker + 'lang.title': '切换界面语言', + + # verbose + 'verbose.title': ' Tool Trace', + 'verbose.hint': ' ↑↓ 选 · PgUp/Dn 滚 · Enter 切换[{field}] · c 复制 · e 导出 · q 退', + 'verbose.empty': '(空)', + + # answer prefix + 'msg.answer_prefix': '[答] {text}', + }, +} + + +def t(key: str, **fmt) -> str: + """Translate `key` to current language; fall back to en then to key itself. + Missing format fields raise KeyError — caller bug, not i18n bug.""" + val = _I18N.get(_LANG, {}).get(key) + if val is None and _LANG != 'en': + val = _I18N.get('en', {}).get(key) + if val is None: + val = key # visible breadcrumb for missing keys + if fmt: + try: + return val.format(**fmt) + except (KeyError, IndexError, ValueError): + return val + return val + + +def tip_count() -> int: + """Number of rotating banner tips (same for every language).""" + return len(_TIPS['en']) + + +def tip(idx: int) -> str: + """Banner tip at `idx`, resolved in the current language so a /language + switch relabels it on the next banner repaint.""" + pool = _TIPS.get(_LANG) or _TIPS['en'] + return pool[idx % len(pool)] if pool else '' + + +def get_lang() -> str: + return _LANG + + +def set_lang(code: str) -> bool: + """Switch active language and persist. Returns True on success.""" + global _LANG + if code not in _SUPPORTED: + return False + _LANG = code + _save_settings({'lang': code}) + return True + + +def supported() -> tuple[str, ...]: + return _SUPPORTED + + +def _detect_system_lang() -> str: + """System language: check LC_ALL → LC_MESSAGES → LANG → locale.getlocale(). + Prefix `zh` → 'zh', else 'en'.""" + for env in ('LC_ALL', 'LC_MESSAGES', 'LANG'): + v = os.environ.get(env) + if v: + if v.lower().startswith('zh'): + return 'zh' + return 'en' + try: + loc = locale.getlocale()[0] or '' + if loc.lower().startswith('zh'): + return 'zh' + except Exception: + pass + return _DEFAULT + + +def _load_settings() -> dict: + try: + with open(_SETTINGS_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_settings(patch: dict) -> None: + cur = _load_settings() + cur.update(patch) + try: + os.makedirs(os.path.dirname(_SETTINGS_PATH), exist_ok=True) + with open(_SETTINGS_PATH, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + except Exception: + pass + + +def init_lang() -> str: + """Resolve and install initial language: persisted > system > default. + Call once at startup; returns the resolved code.""" + global _LANG + saved = _load_settings().get('lang') + if saved in _SUPPORTED: + _LANG = saved + else: + _LANG = _detect_system_lang() + return _LANG + + +# `_t` alias kept so the hundreds of existing `_t(...)` call sites are untouched. +_t = t + +# Resolve language once on import so any module-level string (banner, _CMDS, +# /help) sees the right locale. +init_lang() +# ════════════════════════════════════════════════════════════════════════════ + + +# Module-level `clip` shim: keep sb.py-style `clip.copy(...)` calls +# working without a separate clipboard module — the underlying funcs +# (copy, paste, paste_image) are defined later in this same file. +class _Clip: + @staticmethod + def copy(text): return copy(text) + @staticmethod + def paste(): return paste() + @staticmethod + def paste_image(): return paste_image() +clip = _Clip() + + +def _enable_windows_vt_mode() -> None: + """Enable UTF-8 + ANSI escape processing on Windows consoles when possible.""" + if not _IS_WINDOWS: + return + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + # Make classic conhost/cmd decode UTF-8 bytes written by _w(). This is + # harmless in mintty/Git Bash where these calls usually fail because the + # std handles are pipes/ptys rather than Win32 console handles. + kernel32.SetConsoleOutputCP(65001) + kernel32.SetConsoleCP(65001) + enable_vt = 0x0004 + for handle_id in (-11, -12): # STD_OUTPUT_HANDLE, STD_ERROR_HANDLE + handle = kernel32.GetStdHandle(handle_id) + mode = ctypes.c_uint32() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | enable_vt) + except Exception: + # Safe fallback: modern terminals usually already support ANSI/UTF-8; + # older conhost may render escape codes, but the TUI should not crash. + pass + + +def _enter_utf8_charset() -> None: + """Ask VT-compatible terminals to interpret subsequent bytes as UTF-8.""" + # ESC % G is the ISO-2022/VT sequence for selecting UTF-8. It fixes some + # mintty/Git-Bash launches where the child process inherits a legacy locale + # and mojibakes UTF-8 box drawing/CJK into CP936-looking text. + _w('\x1b%G') + + +def _ptk_keypress_to_bytes(kp) -> bytes: + """Map prompt_toolkit KeyPress objects to tui_v3's internal byte protocol. + + prompt_toolkit normalizes platform-specific console input (Win32 console, + ConPTY, mintty/msys pty) into symbolic Keys. Keep the editor core small by + translating those symbols back to the bytes already handled by _feed/_keys. + """ + try: + from prompt_toolkit.keys import Keys + except Exception: + Keys = None # type: ignore[assignment] + + key = getattr(kp, 'key', None) + data = getattr(kp, 'data', '') or '' + + # Printable text and paste chunks. PTK may deliver a multi-character data + # string for bracketed paste/typeahead; forwarding UTF-8 preserves CJK/emoji. + if isinstance(data, str) and data and (len(data) > 1 or (len(data) == 1 and ord(data) >= 0x20 and data != '\x7f')): + return data.encode('utf-8', 'replace') + + name = getattr(key, 'name', str(key)) + key_s = str(key) + norm = name.lower().replace('_', '-').replace('keys.', '') + norm_s = key_s.lower().replace('_', '-').replace('keys.', '') + aliases = {norm, norm_s} + + def has(*needles: str) -> bool: + return any(n in a for a in aliases for n in needles) + + # Enter submits; Ctrl+J / Shift+Enter insert newline when PTK can distinguish. + if has('controlm', 'c-m') or key_s in ('\r', '\n'): + return b'\r' + if has('controlj', 'c-j', 's-enter', 'shift-enter'): + return b'\n' + + # Navigation. Existing _keys() uses these small control bytes. + if has('up') and has('shift'): + return b'\x1c' + if has('down') and has('shift'): + return b'\x1d' + if has('left') and has('shift'): + return b'\x1e' + if has('right') and has('shift'): + return b'\x1f' + if has('up'): + return b'\x10' + if has('down'): + return b'\x0e' + if has('left'): + return b'\x02' + if has('right'): + return b'\x06' + if has('home'): + return b'\x01' + if has('end'): + return b'\x05' + if has('delete'): + return b'\x7f' + if has('backspace') or data == '\x7f' or data == '\x08': + return b'\x7f' + if has('escape') or data == '\x1b': + return b'\x1b' + + ctrl = { + 'controla': b'\x01', 'c-a': b'\x01', + 'controlb': b'\x02', 'c-b': b'\x02', + 'controlc': b'\x03', 'c-c': b'\x03', + 'controld': b'\x04', 'c-d': b'\x04', + 'controle': b'\x05', 'c-e': b'\x05', + 'controlf': b'\x06', 'c-f': b'\x06', + 'controlh': b'\x7f', 'c-h': b'\x7f', + 'controlj': b'\n', 'c-j': b'\n', + 'controlk': b'\x0b', 'c-k': b'\x0b', + 'controll': b'\x0c', 'c-l': b'\x0c', + 'controln': b'\x0e', 'c-n': b'\x0e', + 'controlp': b'\x10', 'c-p': b'\x10', + 'controlu': b'\x15', 'c-u': b'\x15', + 'controlv': b'\x16', 'c-v': b'\x16', + 'controlx': b'\x18', 'c-x': b'\x18', + 'controly': b'\x19', 'c-y': b'\x19', + 'controlz': b'\x1a', 'c-z': b'\x1a', + } + for a in aliases: + if a in ctrl: + return ctrl[a] + + if isinstance(data, str) and data: + return data.encode('utf-8', 'replace') + return b'' + + +# ──────────────────────────────────────────────────────────────────────────── +# cjk: CJK wrap monkey-patch for Rich +# ──────────────────────────────────────────────────────────────────────────── + +log = logging.getLogger(__name__) + +_CJK_RANGES = ( + (0x4E00, 0x9FFF), (0x3400, 0x4DBF), (0x20000, 0x2A6DF), + (0x2A700, 0x2B73F), (0x2B740, 0x2B81F), (0x2B820, 0x2CEAF), + (0x2CEB0, 0x2EBEF), (0xF900, 0xFAFF), (0x2F800, 0x2FA1F), + (0x3000, 0x303F), (0x3040, 0x309F), (0x30A0, 0x30FF), + (0x31F0, 0x31FF), (0xFF00, 0xFFEF), (0xAC00, 0xD7AF), +) + + +def _is_cjk(ch: str) -> bool: + cp = ord(ch) + return any(lo <= cp <= hi for lo, hi in _CJK_RANGES) + + +def _is_wide(ch: str) -> bool: + try: + from rich.cells import cell_len + return cell_len(ch) == 2 + except ImportError: + return _is_cjk(ch) + + +def install_cjk_wrap() -> bool: + """Monkey-patch Rich's word-wrap to handle CJK char-level breaks. + Returns True on success, False on fallback.""" + try: + import rich._wrap as wrap_mod + from rich.cells import cell_len + except (ImportError, AttributeError) as e: + log.warning("CJK patch skipped: %s", e) + return False + + orig_divide = getattr(wrap_mod, 'divide_line', None) + if orig_divide is None: + log.warning("CJK patch skipped: Rich lacks divide_line") + return False + + def _patched_divide_line(text, width, fold=True): + divides = set() + line_width = 0 + for i, ch in enumerate(text._text if hasattr(text, '_text') else str(text)): + char_w = cell_len(ch) if ch != '\n' else 0 + if line_width + char_w > width and line_width > 0: + if _is_wide(ch) or fold: + divides.add(i) + line_width = char_w + continue + line_width += char_w + if ch == '\n': + line_width = 0 + # Merge with original for non-CJK content + try: + orig_divides = orig_divide(text, width, fold) + divides.update(orig_divides) + except Exception: + pass + return sorted(divides) + + try: + wrap_mod.divide_line = _patched_divide_line + log.info("CJK wrap patch installed for Rich %s", _rich_version()) + return True + except Exception as e: + log.warning("CJK patch failed: %s", e) + return False + + +def _rich_version() -> str: + try: + from importlib.metadata import version + return version('rich') + except Exception: + return '?' + + +# ──────────────────────────────────────────────────────────────────────────── +# clipboard: cross-platform copy/paste via native tools +# ──────────────────────────────────────────────────────────────────────────── + +log = logging.getLogger(__name__) + +_TEMP_DIR = os.path.join(tempfile.gettempdir(), 'genericagent_tui') +_platform = sys.platform +_HAS_WAYLAND = bool(os.environ.get('WAYLAND_DISPLAY')) + + +def _run(cmd: list[str], input: bytes | None = None, timeout: float = 3.0) -> bytes | None: + try: + r = subprocess.run(cmd, input=input, capture_output=True, timeout=timeout) + return r.stdout if r.returncode == 0 else None + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as e: + log.debug("clipboard cmd %s failed: %s", cmd, e) + return None + + +def copy(text: str) -> bool: + data = text.encode('utf-8') + if _platform == 'darwin': + return _run(['pbcopy'], input=data) is not None + if _platform == 'win32': + return _run(['clip.exe'], input=data) is not None + if _HAS_WAYLAND and shutil.which('wl-copy'): + return _run(['wl-copy'], input=data) is not None + if shutil.which('xclip'): + return _run(['xclip', '-selection', 'clipboard'], input=data) is not None + if shutil.which('xsel'): + return _run(['xsel', '--clipboard', '--input'], input=data) is not None + log.warning("No clipboard tool found") + return False + + +def paste() -> str | None: + out: bytes | None = None + if _platform == 'darwin': + out = _run(['pbpaste']) + elif _platform == 'win32': + out = _run(['powershell', '-NoProfile', '-Command', 'Get-Clipboard']) + elif _HAS_WAYLAND and shutil.which('wl-paste'): + out = _run(['wl-paste', '--no-newline']) + elif shutil.which('xclip'): + out = _run(['xclip', '-selection', 'clipboard', '-o']) + elif shutil.which('xsel'): + out = _run(['xsel', '--clipboard', '--output']) + if out is not None: + return out.decode('utf-8', errors='replace') + return None + + +def paste_image() -> str | None: + """Save clipboard image to temp file, return path or None.""" + os.makedirs(_TEMP_DIR, exist_ok=True) + import time + path = os.path.join(_TEMP_DIR, f'clip_{int(time.time()*1000)}.png') + ok = False + if _platform == 'darwin': + script = ( + 'use framework "AppKit"\n' + 'set pb to current application\'s NSPasteboard\'s generalPasteboard()\n' + 'set imgData to pb\'s dataForType:"public.png"\n' + 'if imgData is missing value then error "no image"\n' + 'imgData\'s writeToFile:"' + path + '" atomically:true\n' + ) + ok = _run(['osascript', '-e', script], timeout=5.0) is not None + elif _HAS_WAYLAND and shutil.which('wl-paste'): + data = _run(['wl-paste', '-t', 'image/png']) + if data: + with open(path, 'wb') as f: + f.write(data) + ok = True + elif shutil.which('xclip'): + data = _run(['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o']) + if data and len(data) > 8: + with open(path, 'wb') as f: + f.write(data) + ok = True + return path if ok and os.path.isfile(path) else None + + +def _grab_clipboard_file() -> tuple[str, bool] | None: + """Return (path, is_image) from the clipboard via PIL — ported from v2. + + PIL.ImageGrab.grabclipboard() is the one cross-platform path that also + works on Windows (osascript/xclip/wl-paste below don't). It handles two + shapes: a list of copied file paths, or a raw bitmap Image (saved to a + temp PNG). is_image distinguishes images (→ `[Image #N]`, sent to the + model) from any other file (→ `[File #N]`, expanded to its path).""" + try: + from PIL import ImageGrab, Image + data = ImageGrab.grabclipboard() + except Exception: + return None + if isinstance(data, list): + for item in data: + if isinstance(item, str) and os.path.isfile(item): + return (item, os.path.splitext(item)[1].lower() in _IMAGE_EXTS) + return None + if isinstance(data, Image.Image): + try: + os.makedirs(_TEMP_DIR, exist_ok=True) + path = os.path.join(_TEMP_DIR, f'clip_{int(time.time() * 1000)}.png') + data.save(path, 'PNG') + return (path, True) + except Exception: + return None + return None + + +def _cleanup(): + if os.path.isdir(_TEMP_DIR): + shutil.rmtree(_TEMP_DIR, ignore_errors=True) + +atexit.register(_cleanup) + + +# ──────────────────────────────────────────────────────────────────────────── +# renderer: markdown / ANSI sanitisation / fold +# ──────────────────────────────────────────────────────────────────────────── + +# Comprehensive ANSI sanitization — matches v2's thoroughness +_ANSI_INCOMPLETE_RE = re.compile(r'\x1b\[[0-9;]*$') +_ANSI_DEC_PRIVATE_RE = re.compile(r'\x1b\[\?[0-9;]*[a-zA-Z]') +_ANSI_OSC_RE = re.compile(r'\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?') +_ANSI_MODE_SET_RE = re.compile(r'\x1b[=>][0-9]*') +# Keep SGR (color) codes, strip everything else +_ANSI_SGR_RE = re.compile(r'\x1b\[[0-9;]*m') + +_TURN_MARKER_RE = re.compile(r'\*\*LLM Running \(Turn (\d+)\).*?\*\*') +_META_TAG_RE = re.compile(r'<(?:thinking|summary|tool_use|file_content)>.*?', re.DOTALL) +_TOOL_USE_BLOCK_RE = re.compile(r'```json\s*\{[^}]*"tool_name"[^}]*\}\s*```', re.DOTALL) +_TOOL_USE_TAG_RE = re.compile(r'\s*\{.*?"tool_name"\s*:\s*"([^"]+)".*?\}\s*', re.DOTALL) +_SUMMARY_RE = re.compile(r'\s*(.*?)\s*', re.DOTALL) +_QUAD_BACKTICK_RE = re.compile(r'(`{4,})') +_ASK_USER_RE = re.compile(r'"tool_name"\s*:\s*"ask_user".*?"question"\s*:\s*"([^"]*)"', re.DOTALL) + +# v2 fold_turns helpers (tuiapp_v2.py:240-267). 4-fence stash keeps a tool +# result's `` ``` `` from being misread as a turn boundary; the per-turn +# `` regex uses a negative lookahead so two adjacent summaries don't +# merge; the title cleaner strips fenced code + thinking before extraction. +_FENCE4_STASH_RE = re.compile(r'^`{4,}.*?^`{4,}\n?', re.DOTALL | re.MULTILINE) +_TURN_SPLIT_FOLD_RE = re.compile(r'(\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)') +_SUMMARY_PERTURN_RE = re.compile(r'\s*((?:(?!).)*?)\s*', re.DOTALL) +_TITLE_CLEAN_RE = re.compile(r'`{3,}.*?`{3,}|.*?', re.DOTALL) +_TITLE_ARGS_TAIL_RE = re.compile(r',?\s*args:.*$') + + +@dataclass +class FoldSegment: + title: str + body: str + turn: int + is_last: bool = False + + +@dataclass +class Block: + """A unit of finalized scrollback history, stored as SOURCE (not rendered + lines). Resize replays each block through its renderer at the new width, + so width-baked structures (chip boxes, banner box) reflow correctly. The + actual scrollback bytes above the viewport stay frozen at the old width — + that's a terminal physics constraint — but the viewport and everything + new flows correctly.""" + kind: str # 'user' | 'assistant' | 'plain' | 'banner' + source: str # source text (or '' for banner — regenerated on demand) + tool_n: int = 0 # tool count cached at last render (for stable tids) + + +def sanitize_ansi(text: str) -> str: + """Strip non-SGR ANSI escapes and incomplete sequences from streaming chunks.""" + text = _ANSI_DEC_PRIVATE_RE.sub('', text) + text = _ANSI_OSC_RE.sub('', text) + text = _ANSI_MODE_SET_RE.sub('', text) + text = _ANSI_INCOMPLETE_RE.sub('', text) + return text + + +def _render_checkboxes(text: str) -> str: + """Convert markdown task lists to visual checkboxes.""" + text = re.sub(r'^(\s*[-*+]\s)\[ \]', r'\1☐', text, flags=re.MULTILINE) + text = re.sub(r'^(\s*[-*+]\s)\[x\]', r'\1☑', text, flags=re.MULTILINE | re.IGNORECASE) + return text + + +def strip_meta_tags(text: str) -> str: + """Strip internal tags, render tool_use as readable summaries.""" + def _tool_replace(m): + name = m.group(1) + if name == 'ask_user': + q_match = _ASK_USER_RE.search(m.group(0)) + if q_match: + return f'> {q_match.group(1)}' + return f'🔧 {name}' + text = _TOOL_USE_TAG_RE.sub(_tool_replace, text) + text = _META_TAG_RE.sub('', text) + text = _TOOL_USE_BLOCK_RE.sub('', text) + text = _render_checkboxes(text) + return re.sub(r'\n{3,}', '\n\n', text).strip() + + +def _extract_title(text: str, max_len: int = 72) -> str: + m = _SUMMARY_RE.search(text) + if m: + title = m.group(1).strip() + else: + first = text.strip().split('\n')[0] if text.strip() else '' + title = re.sub(r'^[#*>\-\s]+', '', first).strip() + if len(title) > max_len: + title = title[:max_len - 1] + '…' + return title or '...' + + +def fold_segments(text: str) -> list[FoldSegment]: + """Split agent response into per-turn fold segments.""" + if not text: + return [] + cleaned = _QUAD_BACKTICK_RE.sub(lambda m: '~' * len(m.group(1)), text) + parts = _TURN_MARKER_RE.split(cleaned) + if len(parts) <= 1: + return [FoldSegment(title=_extract_title(text), body=text, turn=1, is_last=True)] + segments: list[FoldSegment] = [] + if parts[0].strip(): + segments.append(FoldSegment(title=_extract_title(parts[0]), body=parts[0], turn=0)) + for i in range(1, len(parts), 2): + turn_num = int(parts[i]) if i < len(parts) else len(segments) + 1 + body = parts[i + 1] if i + 1 < len(parts) else '' + body = body.replace('~' * 4, '````') + segments.append(FoldSegment(title=_extract_title(body), body=body, turn=turn_num)) + if segments: + segments[-1].is_last = True + return segments + + +# Render cache: (content_hash, width) -> rendered object +_render_cache: dict[tuple[int, int], object] = {} +_CACHE_MAX = 200 + + +class HardBreakMarkdown(Markdown): + """Markdown that treats softbreaks as hardbreaks, preserving code blocks.""" + def __init__(self, markup: str, **kwargs): + lines = [] + in_code = False + for line in markup.split('\n'): + stripped = line.strip() + if stripped.startswith('```'): + in_code = not in_code + if in_code: + lines.append(line) + else: + lines.append(line + ' ') + super().__init__('\n'.join(lines), **kwargs) + + +def _markdown_to_text(cleaned: str, width: int) -> Text: + """Render Markdown to a CONCRETE Text (v2 approach). A Textual Static holding + a live rich.markdown.Markdown does not re-composite reliably when scrolled + past the viewport (height measurement is unstable → frozen/blank scroll); + a pre-rendered Text has a fixed line count and scrolls correctly.""" + from io import StringIO + from rich.console import Console + buf = StringIO() + Console(file=buf, width=max(1, width), force_terminal=True, + color_system='truecolor', legacy_windows=False + ).print(HardBreakMarkdown(cleaned), end='') + return Text.from_ansi(buf.getvalue().rstrip('\n')) + + +def render_message(text: str, role: str = 'assistant', width: int = 0) -> Text: + """Render a message to a concrete Text. width<=0 → provisional plain text + (the widget re-renders via on_resize once its real width is known).""" + cleaned = strip_meta_tags(text) if role == 'assistant' else text + if not cleaned.strip(): + cleaned = '...' + if role == 'system': + return Text(cleaned, style='dim') + if width <= 0: + return Text(cleaned) + key = (hash(cleaned), width) + cached = _render_cache.get(key) + if cached is not None: + return cached + try: + result = _markdown_to_text(cleaned, width) + except Exception: + result = Text(cleaned) + if len(_render_cache) >= _CACHE_MAX: + for k in list(_render_cache.keys())[:_CACHE_MAX // 4]: + _render_cache.pop(k, None) + _render_cache[key] = result + return result + + +# ──────────────────────────────────────────────────────────────────────────── +# protocol: AgentBridge + typed events over display_queue +# ──────────────────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class StreamEvent: + text: str + turn: int = 0 + source: str = "user" + +@dataclass(frozen=True) +class DoneEvent: + text: str + turn: int = 0 + source: str = "user" + outputs: list[str] = field(default_factory=list) + +@dataclass(frozen=True) +class AskUserEvent: + question: str + candidates: list[str] = field(default_factory=list) + +@dataclass(frozen=True) +class SystemEvent: + text: str + +@dataclass(frozen=True) +class ErrorEvent: + message: str + exception: Exception | None = None + +AgentEvent = StreamEvent | DoneEvent | AskUserEvent | SystemEvent | ErrorEvent + +_HOOK_KEY = '_tui_v3_ask_user' + + +def _extract_ask_user(ctx: dict | None) -> AskUserEvent | None: + er = (ctx or {}).get('exit_reason') or {} + if er.get('result') != 'EXITED': + return None + payload = er.get('data') or {} + if payload.get('status') != 'INTERRUPT' or payload.get('intent') != 'HUMAN_INTERVENTION': + return None + data = payload.get('data') or {} + return AskUserEvent( + question=data.get('question', ''), + candidates=data.get('candidates', []), + ) + + +class AgentBridge: + """Wraps GenericAgent for the TUI. One bridge per session.""" + + def __init__(self, llm_no: int = 0): + self.agent = GeneraticAgent() + self.agent.llm_no = llm_no + if llm_no and hasattr(self.agent, 'llmclients') and self.agent.llmclients: + self.agent.llmclient = self.agent.llmclients[llm_no % len(self.agent.llmclients)] + self.agent.inc_out = True + self.agent.verbose = True + self.ask_user_queue: queue.Queue[AskUserEvent] = queue.Queue() + self._install_hook() + self._healthy = True + self._init_error: str | None = None + if not getattr(self.agent, 'llmclient', None): + self._healthy = False + self._init_error = _t('err.no_llm') + self._runner = threading.Thread(target=self._run_safe, daemon=True, name=f'ga-tui-agent') + self._runner.start() + + def _run_safe(self): + try: + self.agent.run() + except Exception as e: + self._healthy = False + self._init_error = str(e) + + def _install_hook(self): + if not hasattr(self.agent, '_turn_end_hooks'): + self.agent._turn_end_hooks = {} + self.agent._turn_end_hooks[_HOOK_KEY] = self._on_turn_end + + def _on_turn_end(self, ctx: dict): + ev = _extract_ask_user(ctx) + if ev: + self.ask_user_queue.put(ev) + + def submit(self, query: str, images: list | None = None) -> queue.Queue: + return self.agent.put_task(query, source='user', images=images) + + def abort(self): + self.agent.abort() + + @property + def is_running(self) -> bool: + return self.agent.is_running + + @property + def llm_name(self) -> str: + try: + return self.agent.get_llm_name() + except Exception: + return '?' + + def list_llms(self) -> list[tuple[int, str, bool]]: + return self.agent.list_llms() + + def switch_llm(self, n: int): + self.agent.next_llm(n) + + def drain_display_queue(self, dq: queue.Queue, timeout: float = 0.25): + """Generator: yields typed events from a display_queue.""" + while True: + try: + item = dq.get(timeout=timeout) + except queue.Empty: + yield None + continue + if not isinstance(item, dict): + continue + if 'done' in item: + yield DoneEvent( + text=item['done'], + turn=item.get('turn', 0), + source=item.get('source', 'user'), + outputs=item.get('outputs', []), + ) + break + if 'next' in item: + yield StreamEvent( + text=item['next'], + turn=item.get('turn', 0), + source=item.get('source', 'user'), + ) + + +# ──────────────────────────────────────────────────────────────────────────── +# sb: scrollback-first TUI core (input, paint, flow, ask, /verbose, …) +# ──────────────────────────────────────────────────────────────────────────── + +# Prose hierarchy via ATTRIBUTES only (bold/italic/underline) — NO dim for body +# content (dim on white = unreadable grey). Keep normal prose inherited from the +# surrounding tile; avoid Rich's inline-code reverse without pinning prose dark. +_MD_THEME = Theme({ + 'markdown.h1': 'bold underline', 'markdown.h2': 'bold underline', + 'markdown.h3': 'bold', 'markdown.h4': 'bold', + 'markdown.h5': 'bold', 'markdown.h6': 'bold', + 'markdown.strong': 'bold', 'markdown.em': 'italic', + # Rich's default inline-code ``reverse`` can vanish on themed tiles; keep it + # bold but inherited so it stays readable on both light and dark surfaces. + 'markdown.code': 'bold', 'markdown.code_block': 'none', + 'markdown.block_quote': 'italic', 'markdown.hr': 'none', + 'markdown.link': 'underline', 'markdown.link_url': 'underline', + # Bullet inherits the surrounding foreground (a pinned dark hue vanished on + # dark terminals); bold alone keeps it visible on any background. + 'markdown.item.bullet': 'bold', +}, inherit=True) + + +PROMPT = '❯ ' +CONT = ' ' +# macOS Terminal.app quantises ALL truecolor escapes to their nearest 256-color +# slot, and the slot it picks for #5e6ad2 (iTerm lavender) is 62/#5f5fd7 — that's +# the "blue" border the user sees. It also renders \x1b[2m as a heavy 30%-opacity +# multiply instead of the gentle blend iTerm does — that's the "heavy shadow". +# Branching on TERM_PROGRAM lets iTerm keep its truecolor + dim look, while +# Apple_Terminal uses pinned 256-color slots that match iTerm's RENDERED result. +_IS_APPLE_TERMINAL = os.environ.get('TERM_PROGRAM') == 'Apple_Terminal' +_RST = '\x1b[0m' +if _IS_APPLE_TERMINAL: + _DIM = '\x1b[38;5;244m' # mid-gray — no \x1b[2m, no "shadow" + _ACCENT = '\x1b[38;5;105m' # 256-slot light purple, closest to iTerm rendered look + _BORDER = '\x1b[38;5;146m' # light lavender +else: + _DIM = '\x1b[2m' + _ACCENT = '\x1b[38;2;94;106;210m' # Linear lavender #5e6ad2 + _BORDER = '\x1b[38;5;146m' +_INK_U = '\x1b[38;5;234m' # user ink — near-black, strong (as requested) +# Linear surface ladder: user gets its own panel; AI = plain white surface. +_TILE_U = '\x1b[48;5;251m' + _INK_U # user panel (near-black ink) +_MARK = _ACCENT + '❯' + _RST # prompt mark — the single accent +_BG_TOK = {str(n) for n in list(range(40, 48)) + [49] + list(range(100, 108))} +_SGR_RE = re.compile(r'\x1b\[([0-9;]*)m') +_CSI_ERASE_RE = re.compile(r'\x1b\[[0-9;?]*[JK]') +_SGR_TOKEN_RE = re.compile(r'\x1b\[[0-9;]*m') + + +def _tile(s: str, style: str) -> str: + # re-assert style after every reset so muted-markdown \x1b[0m can't punch + # a hole in the block; \x1b[K fills to the edge (CJK-safe full-width tile). + return style + s.replace(_RST, _RST + style) + '\x1b[K' + _RST + + +def _border(left: str, right: str, width: int, style: str = _BORDER) -> str: + width = max(1, width) + if width == 1: + return style + left + _RST + return style + left + '─' * max(0, width - 2) + right + _RST + + +def _strip_bg(s: str) -> str: + """Drop only BACKGROUND SGR — keep foreground colour (curated syntax/diff + stays, Linear-style functional colour) but no ugly box behind code.""" + def repl(m: re.Match) -> str: + toks = m.group(1).split(';') if m.group(1) else ['0'] + out, i = [], 0 + while i < len(toks): + t = toks[i] + if t == '48': + i += 3 if (i + 1 < len(toks) and toks[i + 1] == '5') else \ + 5 if (i + 1 < len(toks) and toks[i + 1] == '2') else 1 + continue + if t in _BG_TOK: + i += 1; continue + out.append(t); i += 1 + return '\x1b[' + ';'.join(out) + 'm' if out else '\x1b[0m' + return _SGR_RE.sub(repl, s) +_ESC_RE = re.compile(rb'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b.') +_FILE_REF_RE = re.compile(r'@([\w./\-~]+)') +_PASTE_PH_RE = re.compile(r'\[Pasted text #(\d+) \+\d+ lines\]') +_FILE_PH_RE = re.compile(r'\[File #(\d+)\]') +_IMG_PH_RE = re.compile(r'\[Image #(\d+)\]') +# All paste placeholders — used for whole-block delete (v2 parity): backspace +# flush against any of these wipes the entire placeholder, not one char. +_PLACEHOLDER_RES = (_PASTE_PH_RE, _IMG_PH_RE, _FILE_PH_RE) +_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico'} +_TURN_MK_RE = re.compile( + r'\*\*LLM Running \(Turn \d+\)[^\n]*\*\*' # native live-format marker + r'|^[ \t]*Turn \d+\s*\.{3,}[ \t]*$', # plain subagent form on its own line + re.M) +_TOOL_RE = re.compile( + r'🛠️ Tool: `([^`]+)`[^\n]*\n' # 1 = name + r'(`{3,})[^\n]*\n(.*?)\n\2[ \t]*\n*' # 2 = fence delim, 3 = args body + r'(?:' + r'(`{5,})[^\n]*\n(.*?)\n\4[ \t]*\n*' # 4 = result fence (5-bt), 5 = body + r'|' # ─ OR ─ + r'(.*?)(?=^🛠️ Tool: `|^\*\*LLM Running|^|\Z)' # 6 = live exec trace + r')', + re.DOTALL | re.MULTILINE) +# Prompted-style tool wrappers GA models emit AS TEXT in saved logs (no +# structured tool_use block). Fold them into chips too so /continue replays +# match live mode. Whitelist = every name in assets/tools_schema.json + the +# native metadata wrappers; user HTML (
///

/