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 19
 |
@@ -742,7 +742,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
- 微信群 18
 |
+ 微信群 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">.*?" + 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)>.*?(?: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 (