chore: sync framework to upstream lsdefine/GenericAgent @ 1eb8c4c

Content-overlay update of all GA framework files from upstream base
b4393af (2026-05-18) to 1eb8c4c (2026-05-25). Applied as an overlay
because GADesktop and upstream share no common git history.

- 26 framework files brought to upstream: tui_v3 (new), fsapp rework,
  llmcore hardening, plugins/hooks lifecycle hooks, goal_mode prompt
  rewrite, supergrok_proxy, qq/tg/wechat/st apps, cost_tracker py3.9
  compat, docs; removed obsolete install scripts.
- Desktop frontend (frontends/desktop/*) and desktop_bridge.py left
  untouched. Upstream's only overlapping change (bridge mykey.txt->
  mykey.py) is already present in our version, so it is a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dd3xp
2026-05-25 22:03:19 +08:00
parent 400459016a
commit 8f89766fbf
26 changed files with 6125 additions and 739 deletions
+3
View File
@@ -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
+3 -2
View File
@@ -373,7 +373,7 @@ You're also welcome to join the **GenericAgent Community Group** for discussion,
<div align="center">
<table>
<tr>
<td align="center"><strong>WeChat Group 18</strong><br/><img src="assets/images/wechat_group18.jpg" alt="WeChat Group 18 QR" width="240"/></td>
<td align="center"><strong>WeChat Group 19</strong><br/><img src="assets/images/wechat_group19.jpg" alt="WeChat Group 19 QR" width="240"/></td>
</tr>
</table>
</div>
@@ -742,7 +742,7 @@ GenericAgent 通过 **分层记忆 × 最小工具集 × 自主执行循环**
<div align="center">
<table>
<tr>
<td align="center"><strong>微信群 18</strong><br/><img src="assets/images/wechat_group18.jpg" alt="微信群 18 二维码" width="240"/></td>
<td align="center"><strong>微信群 19</strong><br/><img src="assets/images/wechat_group19.jpg" alt="微信群 19 二维码" width="240"/></td>
</tr>
</table>
</div>
@@ -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
---
+10 -4
View File
@@ -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):
+4 -1
View File
@@ -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__))
+126 -19
View File
@@ -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']}")
# 写入
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

-179
View File
@@ -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" <<APPLESCRIPT
on run
set projectPathStr to "${project_path_for_applescript}"
tell application "Terminal"
activate
do script "cd " & quoted form of projectPathStr & " && python3 launch.pyw"
end tell
end run
APPLESCRIPT
osacompile -o "${TMP_DIR}/${APP_NAME}.app" "${TMP_DIR}/${APP_NAME}.applescript"
log_info "Applying GenericAgent icon..."
if [ -f "${ICON_PATH}" ]; then
ICONSET_DIR="${TMP_DIR}/ga-icon.iconset"
mkdir -p "${ICONSET_DIR}"
sips -z 16 16 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16.png" >/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 ""
-111
View File
@@ -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
+390
View File
@@ -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()
+1 -1
View File
@@ -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**。请直接执行、逐步验证;除非用户明确授权,不要做破坏性清理。
+85
View File
@@ -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 SiliconM1/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 可用磁盘空间
+1
View File
@@ -8,6 +8,7 @@ runs its agent on `ga-tui-agent-<id>`, 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
+321 -281
View File
@@ -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()
+5 -1
View File
@@ -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:
+21 -16
View File
@@ -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 <summary> 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'<script>{_js_scroll_fix};{_js_ime_fix}</script>', 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():
+146 -14
View File
@@ -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_>"
_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))
+4609
View File
File diff suppressed because it is too large Load Diff
+167 -18
View File
@@ -290,6 +290,40 @@ class HardBreakMarkdown(Markdown):
HardBreakMarkdown._soft_to_hard(tok.children)
# Rich's Markdown.TableElement adds columns without specifying `overflow`,
# so Rich Table falls back to "ellipsis" — long cell contents get truncated
# with `…` in narrow terminals. Patch to use "fold" instead so cells wrap
# across multiple lines and full content stays visible.
def _patch_markdown_table_overflow():
import rich.markdown as _rmd
from rich.table import Table as _RichTable
from rich import box as _rich_box
def _table_render(self, console, options):
table = _RichTable(
box=_rich_box.SIMPLE,
pad_edge=False,
style="markdown.table.border",
show_edge=True,
collapse_padding=True,
)
if self.header is not None and self.header.row is not None:
for column in self.header.row.cells:
heading = column.content.copy()
heading.stylize("markdown.table.header")
table.add_column(heading, overflow="fold")
if self.body is not None:
for row in self.body.rows:
row_content = [element.content for element in row.cells]
table.add_row(*row_content)
yield table
_rmd.TableElement.__rich_console__ = _table_render
_patch_markdown_table_overflow()
# Rich/Textual wrap treats a continuous CJK run as one indivisible word and
# bumps it whole to the next line when it doesn't fit the remaining space,
# leaving the line tail padded and producing wraps like "AI ↩ 助手...". We patch
@@ -465,6 +499,19 @@ class _MdRender:
_CENTER_LEAD_MIN = 4
def _strip_quote_deco(s: str) -> tuple:
"""Rich Markdown re-emits the `▌ ` blockquote marker on every wrapped visual
line in narrow, but the wide single-line render contains it only once at the
block start. Treat the re-prefix on continuation lines as visual indent that
doesn't consume wide chars. Returns (content_without_deco, deco_width)."""
if not s.startswith(""): # `▌`
return s, 0
rest = s[1:]
if rest.startswith(" "):
return rest[1:], 2
return rest, 1
def _align_md_renders(narrow_raw: str, wide_raw: str):
"""Walk narrow + wide line-by-line; return (source, line_starts, line_indents, line_lengths)."""
narrow = [l.rstrip() for l in narrow_raw.split("\n")]
@@ -505,10 +552,17 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
is_last = (w_idx == W - 1)
while j < K and (accumulated < target or is_last):
nt = run_lines[j]
content = nt.lstrip() if j > g_start - run_start else nt
if j > g_start - run_start:
content, _ = _strip_quote_deco(nt.lstrip())
else:
content = nt
accumulated += len(content)
j += 1
if not is_last and accumulated >= target:
# Each wrap boundary eats one space from the wide line, so
# the narrow side's accumulated content runs (consumed - 1)
# chars short of target at the natural wrap point.
consumed = j - (g_start - run_start)
if not is_last and accumulated + max(0, consumed - 1) >= target:
break
wrap_groups.append(((g_start, run_start + j), w_line))
@@ -549,7 +603,14 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
nt0 = narrow[g_start]
nt0_lead = len(nt0) - len(nt0.lstrip())
wide_lead = len(wide_line) - len(wide_line.lstrip())
is_centered = (single_line and wide_lead > _CENTER_LEAD_MIN and nt0_lead > 0)
# Rich centers H1 against the available width, so wide_lead grows with the
# console width (≈ 5000 at width=10000) while nt0_lead reflects narrow's
# half-padding. Code lines, list/blockquote markers, etc. have wide_lead
# ≈ nt0_lead — without the >=2× guard the heuristic would strip indent
# from any code line with ≥5 leading spaces (e.g. ` print("hi")`),
# causing the visible selection and the copied text to disagree.
is_centered = (single_line and wide_lead > _CENTER_LEAD_MIN and nt0_lead > 0
and wide_lead >= 2 * nt0_lead)
if last_was_content:
source_parts.append("\n")
@@ -574,6 +635,8 @@ def _align_md_renders(narrow_raw: str, wide_raw: str):
else:
indent = len(nt) - len(nt.lstrip())
content = nt.lstrip()
content, deco = _strip_quote_deco(content)
indent += deco
while pointer < len(wide_line) and wide_line[pointer].isspace():
pointer += 1
line_starts[k] = block_start + pointer
@@ -1016,6 +1079,7 @@ COMMANDS = [
("/cost", "[all]", "显示当前会话 token 用量(all = 所有会话)"),
("/export", "clip|<file>|all", "导出最后回复"),
("/restore", "", "恢复上次模型响应日志"),
("/reload-keys", "", "重新加载 mykey.py(不重启)"),
("/quit", "", "退出"),
]
@@ -1139,6 +1203,26 @@ class FoldHeader(SelectableStatic):
self.fold_idx = fold_idx
# User-message display elision: pastes get expanded to full content before send
# (agent needs the whole thing) but the user-visible message echo collapses the
# middle so the chat log doesn't get buried under a 1000-line dump.
_USER_DISPLAY_HEAD_LINES = 10
_USER_DISPLAY_TAIL_LINES = 5
_USER_DISPLAY_MAX_LINES = _USER_DISPLAY_HEAD_LINES + _USER_DISPLAY_TAIL_LINES + 4
def _elide_user_display(text: str) -> str:
"""Collapse middle of long user messages: keep head + tail, summarize gap."""
lines = text.split("\n")
n = len(lines)
if n <= _USER_DISPLAY_MAX_LINES:
return text
omitted = n - _USER_DISPLAY_HEAD_LINES - _USER_DISPLAY_TAIL_LINES
head = lines[:_USER_DISPLAY_HEAD_LINES]
tail = lines[-_USER_DISPLAY_TAIL_LINES:]
return "\n".join(head + [f"⋯ 省略 {omitted} 行 ⋯"] + tail)
def _read_clipboard_text() -> str:
try:
import tkinter as tk
@@ -1881,6 +1965,7 @@ class GenericAgentTUI(App[None]):
"stop": self._cmd_stop, "llm": self._cmd_llm, "export": self._cmd_export,
"restore": self._cmd_restore, "btw": self._cmd_btw, "review": self._cmd_review,
"continue": self._cmd_continue, "cost": self._cmd_cost,
"reload-keys": self._cmd_reload_keys,
"quit": self._cmd_quit, "exit": self._cmd_quit,
}
try:
@@ -2357,6 +2442,19 @@ class GenericAgentTUI(App[None]):
if palette.highlighted is not None:
palette.action_select()
async def _on_paste(self, event: events.Paste) -> None:
# Windows Terminal yanks window focus when its large-paste-warning dialog
# pops, and the focus doesn't return to any specific widget after confirm.
# Without a focused widget Textual routes the Paste event to the App
# bubble — InputArea never sees it. Forward it back to the input box.
try:
inp = self.query_one("#input", InputArea)
except Exception:
return
inp.focus()
await inp._on_paste(event)
event.stop(); event.prevent_default()
def on_click(self, event: events.Click) -> None:
w = event.widget
if isinstance(w, FoldHeader):
@@ -2434,6 +2532,17 @@ class GenericAgentTUI(App[None]):
container = self.query_one("#messages", VerticalScroll)
except Exception:
return
# Preserve scroll position across remount. "Near the bottom" snaps to
# bottom afterwards so streaming output stays visible; mid-scroll keeps
# the same scroll_y so resize/sidebar-toggle don't yank the user away
# from what they're reading. 2-line tolerance covers rounding.
try:
was_at_bottom = (container.scroll_y + container.size.height
>= container.virtual_size.height - 2)
prev_scroll_y = container.scroll_y
except Exception:
was_at_bottom = True
prev_scroll_y = 0
container.remove_children()
for m in self.current.messages:
m._role_widget = None
@@ -2444,7 +2553,10 @@ class GenericAgentTUI(App[None]):
m._spinner_widget = None
for m in self.current.messages:
self._mount_message(container, m)
container.scroll_end(animate=False)
if was_at_bottom:
container.scroll_end(animate=False)
else:
container.scroll_to(y=prev_scroll_y, animate=False)
def on_text_area_changed(self, event: TextArea.Changed) -> None:
if event.text_area.id != "input":
@@ -2484,6 +2596,16 @@ class GenericAgentTUI(App[None]):
self._resize_input(inp)
if not text:
return
# Pick up mykey.py edits without restart: load_llm_sessions() is a
# cheap mtime check when nothing changed; on change it rebuilds the
# llm clients in place, migrating history. Done per submit so a user
# who tweaks mykey then sends a message gets the new config.
try:
sess = self.sessions.get(self.current_id)
if sess is not None and hasattr(sess, "agent"):
sess.agent.load_llm_sessions()
except Exception:
pass
if text.startswith("/"):
parts = text.split(maxsplit=1)
cmd = parts[0][1:].lower()
@@ -2846,6 +2968,30 @@ class GenericAgentTUI(App[None]):
self._system(f"Stop failed: {e}")
self._refresh_all()
def _cmd_reload_keys(self, args, raw):
# Force rebuild of every session's llmclients from a fresh mykey.py.
# reload_mykeys() uses a module-level mtime cache, so the first agent
# to call it consumes the "changed" signal and subsequent agents see
# changed=False (and skip rebuild). Invalidate the cache before each
# agent so every session picks up the new config.
try:
import llmcore
except Exception as e:
self._system(f"❌ 无法 import llmcore: {e}"); return
n_ok = n_fail = 0
for sess in self.sessions.values():
agent = getattr(sess, "agent", None)
if agent is None:
continue
try:
llmcore._mykey_mtime = None
agent.load_llm_sessions()
n_ok += 1
except Exception:
n_fail += 1
msg = f"🔑 已重载 mykey.py{n_ok} 个会话)" + (f"{n_fail} 个失败" if n_fail else "")
self._system(msg)
def _cmd_llm(self, args, raw):
sess = self.current
if args:
@@ -2973,20 +3119,17 @@ class GenericAgentTUI(App[None]):
sessions = continue_list(exclude_pid=os.getpid())
if not sessions:
self._system("❌ 没有可恢复的历史会话"); return
LIMIT = 20
choices = []
try:
import session_names as _sn
except Exception:
_sn = None
for path, mtime, first, n in sessions[:LIMIT]:
for path, mtime, first, n in sessions:
preview = (first or "(无法预览)").replace("\n", " ").strip()[:50]
nm = _sn.name_for(path) if _sn else ""
tag = f"{nm} · " if nm else ""
choices.append((f"{_short_age(mtime)} · {tag}{n}轮 · {preview}", path))
head = "选择要恢复的会话 (↑/↓ 移动,→/Enter 确认,Esc 取消)"
if len(sessions) > LIMIT:
head += f" [仅显示最近 {LIMIT}/{len(sessions)}]"
head = f"选择要恢复的会话 ({len(sessions)} 条 · ↑/↓ 移动,→/Enter 确认,Esc 取消)"
msg = ChatMessage(
role="system", content=head, kind="choice", choices=choices,
on_select=lambda v: self._do_continue_restore(v),
@@ -3948,11 +4091,13 @@ class GenericAgentTUI(App[None]):
else:
content = _TURN_MARKER_RE.sub("", seg.get("content", ""), count=1)
# While streaming, the tail text segment grows every chunk — Markdown
# parsing it per chunk is the streaming-lag root cause. Render as plain
# Text during streaming; _stream_update_assistant swaps in the real
# Markdown render once m.done flips True.
# parsing it per chunk is the streaming-lag root cause. Render via
# Text.from_ansi during streaming (O(n) scan, no reflow) so SGR codes
# in the chunk become styles instead of literal `[31m` glyphs;
# _stream_update_assistant swaps in the real Markdown render once
# m.done flips True.
if i == last_i and not m.done:
out.append(("text", Text(content, style=C_FG), None))
out.append(("text", Text.from_ansi(content, style=C_FG), None))
else:
out.append(("text", cached_render(content), None))
if m.done:
@@ -4162,7 +4307,7 @@ class GenericAgentTUI(App[None]):
container.mount(m._body_widget)
return
if m.role == "user":
body = Text(); body.append("> ", style=C_DIM); body.append(m.content, style=C_FG)
body = Text(); body.append("> ", style=C_DIM); body.append(_elide_user_display(m.content), style=C_FG)
for path in m.image_paths:
body.append(f"\n📎 {path}", style=C_MUTED)
m._body_widget = SelectableStatic(body, classes="msg")
@@ -4252,9 +4397,13 @@ class GenericAgentTUI(App[None]):
last_seg = fold_turns(cleaned)[-1]
last_text = _TURN_MARKER_RE.sub("", last_seg.get("content", ""), count=1)
last_widget = m._segment_widgets[-1]
# During streaming use plain Text — Markdown parse per chunk is O(chunks ×
# turn_len). Only on the terminal `done` chunk do we render Markdown once
# and swap, restoring code blocks / lists / inline styling and clean-copy.
# During streaming use Text.from_ansi — Markdown parse per chunk is
# O(chunks × turn_len), but raw Text() would render upstream SGR codes
# as literal `[31m` glyphs (visible as ANSI garbage until done flips
# True or a resize forces remount). from_ansi is O(n) and resolves
# the codes into Rich styles. On the terminal `done` chunk we render
# Markdown once and swap, restoring code blocks / lists / inline
# styling and clean-copy.
if m.done:
rendered = self._render_md(last_text, width)
if isinstance(rendered, _MdRender):
@@ -4264,7 +4413,7 @@ class GenericAgentTUI(App[None]):
last_widget.update(rendered)
else:
last_widget._ga_render = None
last_widget.update(Text(last_text, style=C_FG))
last_widget.update(Text.from_ansi(last_text, style=C_FG))
if m.done and m._spinner_widget is not None:
# Convert the live spinner into the post-turn ⠿ card in place.
self._capture_done_summary(m)
+29 -6
View File
@@ -1,4 +1,4 @@
import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, webbrowser, hashlib, math
import os, sys, re, threading, queue, time, socket, json, struct, base64, uuid, hashlib, math
from pathlib import Path
from urllib.parse import quote
import requests, qrcode
@@ -7,6 +7,14 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent
# ── AuthExpired (errcode -14 from getUpdates) ──
class AuthExpired(Exception):
"""Bot token expired or invalid (errcode=-14)."""
pass
# ── Per-user abort flags (shared between on_message invocations) ──
_task_aborted: dict = {} # uid -> True (set by /stop, read by _handle)
# ── WxBotClient (inline from wx_bot_client.py) ──
for _k in ('HTTPS_PROXY', 'https_proxy'):
os.environ.pop(_k, None) # avoid inherited proxy breaking WeChat long-poll SSL
@@ -62,7 +70,7 @@ class WxBotClient:
print(f'[QR登录] ID: {qr_id}')
if url:
img = self._tf.parent / 'wx_qr.png'
qrcode.make(url).save(str(img)); webbrowser.open(str(img))
qrcode.make(url).save(str(img)) # 保存到文件,不弹浏览器
qr = qrcode.QRCode(border=1); qr.add_data(url); qr.make(fit=True); qr.print_ascii(invert=True)
last = ''
while True:
@@ -88,7 +96,10 @@ class WxBotClient:
return []
if resp.get('errcode'):
print(f'[getUpdates] err: {resp.get("errcode")} {resp.get("errmsg","")}')
if resp['errcode'] == -14: self._buf = ''; self._save()
if resp['errcode'] == -14:
self._buf = ''; self.token = ''; self.bot_id = ''
self._save(bot_token='', ilink_bot_id='')
raise AuthExpired(resp.get('errmsg',''))
return []
nb = resp.get('get_updates_buf', '')
if nb: self._buf = nb; self._save()
@@ -229,6 +240,7 @@ class WxBotClient:
try: on_message(self, msg)
except Exception as e: print(f'[Bot] 回调异常: {e}')
except KeyboardInterrupt: print('[Bot] 退出'); break
except AuthExpired: raise
except Exception as e: print(f'[Bot] 异常: {e}5s重试'); time.sleep(5)
# ── Unified media download (IMAGE/VIDEO/FILE/VOICE) ──
@@ -311,6 +323,8 @@ def on_message(bot, msg):
# Commands
if text in ('/stop', '/abort'):
agent.abort()
_task_aborted[uid] = True
print(f'[WX] /stop set _task_aborted[{uid}]', file=sys.__stdout__)
return
if text.startswith('/llm'):
args = text.split()
@@ -371,7 +385,9 @@ def on_message(bot, msg):
_typing_stop.set()
if 'done' in item: result, done = item['done'], item.get('outputs', [])
rest = _clean('\n\n'.join(done[sent:] + ['\n\n[任务已完成]']).strip())
aborted = _task_aborted.pop(uid, False)
tag = '[已停止]' if aborted else '[任务已完成]'
rest = _clean('\n\n'.join(done[sent:] + ['\n\n' + tag]).strip())
if rest: _wx_send(rest[-3000:])
files = re.findall(r'\[FILE:([^\]]+)\]', result)
@@ -391,16 +407,23 @@ def on_message(bot, msg):
threading.Thread(target=_handle, daemon=True).start()
if __name__ == '__main__':
_do_relogin = '--relogin' in sys.argv
try: _lock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); _lock.bind(('127.0.0.1', 19531))
except OSError: print('[WeChat] Another instance running, exiting.'); sys.exit(1)
_logf = open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp', 'wechatapp.log'), 'a', encoding='utf-8', buffering=1)
sys.stdout = sys.stderr = _logf
print(f'[NEW] Process starting {time.strftime("%m-%d %H:%M")}')
bot = WxBotClient()
if not bot.token:
if _do_relogin or not bot.token:
if not sys.stdout.isatty():
print('[Bot] no token and not interactive, exit.'); sys.exit(1)
sys.stdout = sys.stderr = sys.__stdout__ # restore for QR display
bot.login_qr()
sys.stdout = sys.stderr = _logf
threading.Thread(target=agent.run, daemon=True).start()
print(f'WeChat Bot 已启动 (bot_id={bot.bot_id})', file=sys.__stdout__)
bot.run_loop(on_message)
try:
bot.run_loop(on_message)
except AuthExpired:
print('[Bot] token expired, exit.', file=sys.__stdout__)
sys.exit(2)
+7
View File
@@ -65,6 +65,11 @@ COMMANDS = {
"desc": "启动终端图形界面(Textual),适合纯终端环境或 SSH",
"cmd": ["python", "{FRONTENDS}/tuiapp.py"],
},
"tui2": {
"help": "启动终端 TUI v2 (tuiapp_v2)",
"desc": "启动增强版终端图形界面(Textual v2),更多功能更好的体验",
"cmd": ["python", "{FRONTENDS}/tuiapp_v2.py"],
},
"cli": {
"help": "启动 CLI 对话 (agentmain)",
"desc": "启动命令行交互对话模式,最轻量的使用方式",
@@ -151,6 +156,8 @@ def main():
ga gui 启动桌面 GUI
ga web 启动 Web 增强版
ga web --native 启动 Web 基础版(桌面壳)
ga tui 启动终端 TUI (v1)
ga tui2 启动终端 TUI (v2 增强版)
ga pet 启动桌面宠物 v2
ga launch 启动 webview 桌面壳
ga list 列出所有命令
+18 -13
View File
@@ -2,29 +2,34 @@ import os, json, re, time, requests, sys, threading, urllib3, base64, importlib,
from datetime import datetime
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
_RESP_CACHE_KEY = str(uuid.uuid4())
_ROOT = os.path.dirname(os.path.abspath(__file__))
if _ROOT not in sys.path: sys.path.append(_ROOT)
def _load_mykeys():
global _mykey_path
try:
import mykey; importlib.reload(mykey); _mykey_path = mykey.__file__
return {k: v for k, v in vars(mykey).items() if not k.startswith('_')}
except ImportError: pass
_mykey_path = p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
if not os.path.exists(p): raise Exception('[ERROR] mykey.py or mykey.json not found, please create one from mykey_template.')
with open(p, encoding='utf-8') as f: return json.load(f)
except ImportError as e:
if getattr(e, 'name', None) != 'mykey':
raise Exception(f'[ERROR] mykey.py found but failed to import: {e}') from e
except SyntaxError as e:
raise Exception(f'[ERROR] mykey.py has syntax error: {e}') from e
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mykey.json')
if not os.path.exists(p): raise Exception('[ERROR] mykey.py not found in sys.path and mykey.json not found. Run "python configure_mykey.py" or copy mykey_template.py to mykey.py and fill in your keys.')
with open(_mykey_path := p, encoding='utf-8') as f: return json.load(f)
_mykey_path = _mykey_mtime = None
def reload_mykeys():
global _mykey_mtime
mt = os.stat(_mykey_path).st_mtime_ns if _mykey_path else -1
if mt == _mykey_mtime: return globals().get('mykeys', {}), False
mk = _load_mykeys(); _mykey_mtime = os.stat(_mykey_path).st_mtime_ns
print(f'[Info] Load mykeys from {_mykey_path}')
globals().update(mykeys=mk)
if mk.get('langfuse_config'):
try: from plugins import langfuse_tracing
except Exception: pass
return mk, True
try:
mt = os.stat(_mykey_path).st_mtime_ns if _mykey_path else -1
if mt == _mykey_mtime: return globals().get('mykeys', {}), False
mk = _load_mykeys(); _mykey_mtime = os.stat(_mykey_path).st_mtime_ns
print(f'[Info] Load mykeys from {_mykey_path}')
globals().update(mykeys=mk)
return mk, True
except: return globals().get('mykeys', {}), False
def __getattr__(name): # once guard in PEP 562
if name == 'mykeys': return reload_mykeys()[0]
+14 -7
View File
@@ -9,18 +9,19 @@ _MASK = hashlib.sha256(f"{_user}@ga_keychain".encode()).digest()
def _xor(data: bytes) -> bytes:
return bytes(b ^ _MASK[i % len(_MASK)] for i, b in enumerate(data))
print('# SecretStr.use() to get raw, do not print raw value! | keys.ls() to list all keys')
class SecretStr:
def __init__(self, name: str, val: str):
self._name, self._val = name, val
def use(self) -> str:
return self._val
def use(self) -> str: return self._val
def __repr__(self):
n = len(self._val)
if n <= 4: preview = '***'
elif n <= 16: preview = f"{self._val[:3]}···{self._val[-3:]}"
elif n <= 40: preview = f"{self._val[:6]}···{self._val[-6:]} len={n}"
else: preview = f"{self._val[:10]}···{self._val[-6:]} len={n}"
return f"SecretStr({self._name}={preview}) # .use() to get raw, do not print raw value"
return f"SecretStr({self._name}={preview})"
__str__ = __repr__
class _Keys:
@@ -28,19 +29,25 @@ class _Keys:
self._d = {}
if _PATH.exists():
try:
self._d = json.loads(_xor(_PATH.read_bytes()))
raw = json.loads(_xor(_PATH.read_bytes()))
self._d = {k: SecretStr(k, v) for k, v in raw.items()}
except Exception as e:
print(f"[keychain] WARNING: failed to load {_PATH}: {e}")
print(f"[keychain] Starting with empty keychain. Old file kept as .bak")
_PATH.rename(_PATH.with_suffix('.enc.bak'))
def _save(self):
raw = {k: v.use() for k, v in self._d.items()}
_PATH.write_bytes(_xor(json.dumps(raw).encode()))
def __getattr__(self, k):
if k.startswith('_'): raise AttributeError(k)
if k not in self._d: raise KeyError(f"No secret: {k}")
return SecretStr(k, self._d[k])
return self._d[k]
def __repr__(self):
return f"Keychain({len(self._d)} secrets: {', '.join(self._d.keys())})"
def set(self, k, v=None, *, file=None):
if file: v = pathlib.Path(file).read_text().strip()
self._d[k] = v
_PATH.write_bytes(_xor(json.dumps(self._d).encode()))
self._d[k] = SecretStr(k, v)
self._save()
def ls(self): return list(self._d.keys())
keys = _Keys()
+67
View File
@@ -0,0 +1,67 @@
import os
import sys
import importlib
# 模块级注册表: event_name -> [callback, ...]
_registry = {}
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def register(event):
def decorator(fn):
_registry.setdefault(event, []).append(fn)
return fn
return decorator
def trigger(event, ctx: dict):
for fn in _registry.get(event, []):
try:
r = fn(ctx)
if isinstance(r, dict):
ctx = r
except Exception as e:
sys.stderr.write(f"[hooks] {event} callback error: {e}\n")
return ctx
def unregister(event, fn):
try:
_registry[event] = [f for f in _registry[event] if f is not fn]
except KeyError:
pass
def clear(event=None):
if event:
_registry.pop(event, None)
else:
_registry.clear()
def has(event):
return bool(_registry.get(event))
def discover_and_load(plugin_dir=None):
if plugin_dir is None:
plugin_dir = os.path.join(_PROJECT_ROOT, 'plugins')
if not os.path.isdir(plugin_dir):
return
parent = os.path.dirname(plugin_dir)
if parent not in sys.path:
sys.path.insert(0, parent)
for fn in sorted(os.listdir(plugin_dir)):
if fn.startswith('_') or not fn.endswith('.py'):
continue
name = fn[:-3]
load(name)
def load(name):
try:
importlib.import_module(f'plugins.{name}')
return True
except Exception as e:
sys.stderr.write(f"[hooks] plugin '{name}' load failed: {e}\n")
return False
+86 -62
View File
@@ -1,9 +1,11 @@
"""Opt-in Langfuse tracing. Self-activates on import if langfuse_config exists in mykey.
"""Langfuse tracing via hook system. Self-activates on import if langfuse_config exists in mykey.
Hooks only via monkey-patch so core files stay untouched:
- agent_loop.agent_runner_loop -> outer agent trace (parent of all below)
- llmcore._write_llm_log -> generation span (Prompt=start, Response=end)
- BaseHandler.tool_before/after -> tool span
Replaces old monkey-patch approach with hooks on:
- agent_before / agent_after -> agent trace
- llm_before / llm_after -> generation span
- tool_before / tool_after -> tool span
Usage tracking (SSE parser wrapping) stays as internal llmcore patch.
"""
import threading, sys
@@ -16,21 +18,82 @@ except Exception:
_lf = None
if _lf:
import llmcore, agent_loop
import plugins.hooks as hooks, llmcore
_tls = threading.local()
_orig_log = llmcore._write_llm_log
def _patched_log(label, content, log_path=None):
# ── Agent trace ──────────────────────────────────────────────
@hooks.register('agent_before')
def _on_agent_before(ctx):
try:
if label == 'Prompt':
_tls.gen = _lf.start_observation(name='llm.chat', as_type='generation', input=content[:20000])
_tls.usage = None
elif label == 'Response' and getattr(_tls, 'gen', None) is not None:
_tls.gen.update(output=content[:20000], usage_details=getattr(_tls, 'usage', None))
_tls.gen.end(); _tls.gen = None
except Exception: pass
return _orig_log(label, content, log_path)
llmcore._write_llm_log = _patched_log
_tls.trace_obs = _lf.start_observation(
name='agent.task', as_type='agent',
input={'user_input': ctx.get('user_input', '')})
except Exception:
_tls.trace_obs = None
@hooks.register('agent_after')
def _on_agent_after(ctx):
try:
obs = getattr(_tls, 'trace_obs', None)
if obs:
obs.update(output=ctx.get('exit_reason'))
obs.end()
_tls.trace_obs = None
_lf.flush()
except Exception:
pass
# ── LLM generation span (replaces _write_llm_log patch) ─────
@hooks.register('llm_before')
def _on_llm_before(ctx):
try:
_tls.gen = _lf.start_observation(
name='llm.chat', as_type='generation',
input=str(ctx.get('messages', ''))[:20000])
_tls._usage = None
except Exception:
_tls.gen = None
@hooks.register('llm_after')
def _on_llm_after(ctx):
try:
gen = getattr(_tls, 'gen', None)
if gen:
gen.update(output=str(ctx.get('response', ''))[:20000],
usage_details=getattr(_tls, '_usage', None))
gen.end()
_tls.gen = None
except Exception:
pass
# ── Tool spans (replaces tool_before/after_callback patches) ─
@hooks.register('tool_before')
def _on_tool_before(ctx):
try:
name = ctx.get('tool_name', '?')
args = {k: v for k, v in (ctx.get('args') or {}).items() if not k.startswith('_')}
if not hasattr(_tls, 'tstack'): _tls.tstack = []
_tls.tstack.append(_lf.start_observation(name=name, as_type='tool', input=args))
except Exception:
pass
@hooks.register('tool_after')
def _on_tool_after(ctx):
try:
stack = getattr(_tls, 'tstack', [])
if stack:
sp = stack.pop()
ret = ctx.get('ret')
out = {'data': ret.data, 'next_prompt': ret.next_prompt,
'should_exit': ret.should_exit} if ret else None
sp.update(output=out); sp.end()
except Exception:
pass
# ── Usage tracking: tee SSE data for token counts ───────────
def _extract_usage(buf):
u = {}
@@ -72,51 +135,12 @@ if _lf:
for ln in resp_lines:
buf.append(ln); yield ln
ret = yield from orig(tee(), *a, **kw)
try: _tls.usage = _extract_usage(buf)
except Exception: pass
try:
_tls._usage = _extract_usage(buf)
except Exception:
pass
return ret
return wrapped
llmcore._parse_claude_sse = _wrap_parser(llmcore._parse_claude_sse)
llmcore._parse_openai_sse = _wrap_parser(llmcore._parse_openai_sse)
_orig_before = agent_loop.BaseHandler.tool_before_callback
_orig_after = agent_loop.BaseHandler.tool_after_callback
def _patched_before(self, tool_name, args, response):
try:
if not hasattr(_tls, 'tstack'): _tls.tstack = []
a = {k: v for k, v in args.items() if k != '_index'}
_tls.tstack.append(_lf.start_observation(name=tool_name, as_type='tool', input=a))
except Exception: pass
return _orig_before(self, tool_name, args, response)
def _patched_after(self, tool_name, args, response, ret):
try:
if getattr(_tls, 'tstack', None):
sp = _tls.tstack.pop()
out = {'data': ret.data, 'next_prompt': ret.next_prompt, 'should_exit': ret.should_exit} if ret else None
sp.update(output=out); sp.end()
except Exception: pass
return _orig_after(self, tool_name, args, response, ret)
agent_loop.BaseHandler.tool_before_callback = _patched_before
agent_loop.BaseHandler.tool_after_callback = _patched_after
_orig_loop = agent_loop.agent_runner_loop
def _patched_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw):
try: cm = _lf.start_as_current_observation(name='agent.task', as_type='agent', input={'user_input': user_input})
except Exception: cm = None
if cm is None:
ret = yield from _orig_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw); return ret
with cm as sp:
ret = yield from _orig_loop(client, system_prompt, user_input, handler, tools_schema, *a, **kw)
try: sp.update(output=ret)
except Exception: pass
try: _lf.flush()
except Exception: pass
return ret
agent_loop.agent_runner_loop = _patched_loop
for _m in list(sys.modules.values()):
if _m and getattr(_m, 'agent_runner_loop', None) is _orig_loop:
try: setattr(_m, 'agent_runner_loop', _patched_loop)
except Exception: pass
llmcore._parse_openai_sse = _wrap_parser(llmcore._parse_openai_sse)
+5
View File
@@ -23,6 +23,11 @@ ui = [
"streamlit>=1.28",
"pywebview>=4.0",
"textual>=0.70",
# tui_v3 (scrollback-first TUI) — prompt_toolkit replaces textual; rich is
# now a direct dependency (was transitive via textual in tui_v2).
"prompt_toolkit>=3.0,<4",
"rich>=13.0",
"pillow>=9.0",
]
all-frontends = [
"python-telegram-bot>=20.0",
+7 -4
View File
@@ -32,10 +32,13 @@ CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
已用 {elapsed_min:.0f} 分钟剩余约 {remaining_min:.0f} 分钟 {turn} 次唤醒
你正在 Goal Mode 下工作
1. 禁止说"已完成,是否继续"预算没到就不准停不准提前交付围绕核心产出不要额外产出
2. cwd 下建立工作文件夹存放成果和进度复杂任务可使用 plan 模式
3. 如果当前方向做完了主动找下一个改进点测试/边界case/性能/安全/文档/代码质量
4. 找不到改进点扩大视野关联模块上下游依赖用户体验错误提示日志可观测性上网搜索找其他路径翻记忆里面有无相关
1. 禁止说"已完成,是否继续"预算没到就不准停不准提前交付围绕核心成果质量不要额外产出
2. cwd 下建立工作文件夹存放成果和进度读取记忆中可能对成果质量有帮助的原则
3. 核心完成后选你认为最能提升成果质量的方向深入打磨
如果多轮都是同类型的小修换一个完全不同的角度重新审视
找到新的薄弱点再深入包括但不限于测试/边界case/性能/安全/文档/代码质量
4. 换角度的方法假装你是第一次看到这个成果的使用者/审阅者/攻击者
找到它最容易出问题的地方然后修它
5. 要为了目标持续推进在工作文件夹中记录进度不要更新全局记忆
"""