chore: sync framework to upstream lsdefine/GenericAgent @ 1eb8c4c
Content-overlay update of all GA framework files from upstream baseb4393af(2026-05-18) to1eb8c4c(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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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**。请直接执行、逐步验证;除非用户明确授权,不要做破坏性清理。
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# GenericAgent 桌面版安装指南
|
||||
|
||||
## 📦 安装步骤
|
||||
|
||||
### 第一步:打开安装包
|
||||
|
||||
双击下载的 `GenericAgent_x.x.x_aarch64.dmg` 文件,会弹出一个安装窗口。
|
||||
|
||||
将左边的 **GenericAgent** 图标拖到右边的 **Applications** 文件夹图标上,等待拷贝完成。
|
||||
|
||||
拷贝完成后,可以右键点击桌面上的 DMG 图标,选择「推出」来关闭安装包。
|
||||
|
||||
---
|
||||
|
||||
### 第二步:首次打开前的准备(重要)
|
||||
|
||||
由于本应用暂未通过 Apple 官方签名认证,macOS 会阻止首次打开。这是正常的安全提示,不代表应用有问题。
|
||||
|
||||
请按以下步骤解除限制:
|
||||
|
||||
#### 1. 打开「终端」应用
|
||||
|
||||
不知道终端是什么?别担心,它就是一个可以输入命令的工具。打开方式:
|
||||
|
||||
- 按下键盘上的 `Command(⌘) + 空格键`,会弹出搜索框(Spotlight)
|
||||
- 输入 `终端` 或 `Terminal`,按回车键打开
|
||||
|
||||
你会看到一个黑色或白色的文字窗口,里面有一个闪烁的光标,这就是终端。
|
||||
|
||||
#### 2. 输入解除限制的命令
|
||||
|
||||
在终端窗口中,复制粘贴以下这行命令(整行复制,一个字都不要漏��:
|
||||
|
||||
```
|
||||
xattr -cr /Applications/GenericAgent.app
|
||||
```
|
||||
|
||||
粘贴方法:在终端窗口里按 `Command(⌘) + V`
|
||||
|
||||
然后按 `回车键(Enter)` 执行。
|
||||
|
||||
> 如果终端要求输入密码,输入你的 Mac 开机密码(输入时不会显示任何字符,这是正常的),然后按回车。
|
||||
|
||||
执行完毕后,终端不会有任何提示,这代表成功了。
|
||||
|
||||
#### 3. 打开 GenericAgent
|
||||
|
||||
现在可以正常打开应用了:
|
||||
|
||||
- 打开 Finder → 侧边栏点击「应用程序」
|
||||
- 找到 **GenericAgent**,双击打开
|
||||
|
||||
首次打开可能还会弹出一个确认框,点击「打开」即可。之后就不会再弹出了。
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: 提示「GenericAgent 已损坏,无法打开」怎么办?
|
||||
|
||||
这不是真的损坏,是 macOS 的安全机制。请回到第二步,确保在终端中执行了 `xattr -cr` 命令。
|
||||
|
||||
### Q: 提示「无法打开,因为无法验证开发者」怎么办?
|
||||
|
||||
方法一(推荐):执行第二步的终端命令。
|
||||
|
||||
方法二:右键点击 GenericAgent.app → 选择「打开」→ 在弹出的对话框中点击「打开」。
|
||||
|
||||
### Q: 我的 Mac 是 Intel 芯片的,能用吗?
|
||||
|
||||
当前版本仅支持 Apple Silicon(M1/M2/M3/M4)芯片的 Mac。如果你的 Mac 是 2020 年之前购买的,大概率是 Intel 芯片,暂时无法使用本安装包。
|
||||
|
||||
查看方法:点击左上角 → 「关于本机」,如果芯片一栏显示 Apple M1/M2/M3/M4,就可以使用。
|
||||
|
||||
### Q: 终端命令执行后没有任何反应?
|
||||
|
||||
没有反应就是成功了。Unix/macOS 的设计哲学是「没有消息就是好消息」。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 系统要求
|
||||
|
||||
- macOS 12 (Monterey) 或更高版本
|
||||
- Apple Silicon 芯片(M1/M2/M3/M4)
|
||||
- 约 50MB 可用磁盘空间
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+167
-18
@@ -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
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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. 要为了目标持续推进,在工作文件夹中记录进度,不要更新全局记忆。
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user