diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 6055a627c..175d71997 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -7,6 +7,7 @@ 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 run_register(): return {} +def _wechat_scan(): + """微信 iLink 扫码登录,保存 token 到 ~/.wxbot/token.json,返回 env_vals""" + print(f"\n {C['cyan']}📱 正在启动微信 iLink 扫码登录...{C['reset']}") + print(f" {C['dim']} 请用微信扫描终端二维码,完成授权后自动获取凭据。{C['reset']}\n") + + # 确保项目根在路径中,以便导入 frontends/wechatapp + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + try: + from frontends.wechatapp import WxBotClient + except ImportError as e: + print(f"\n {C['yellow']}⚠ 无法导入 WxBotClient: {e}{C['reset']}") + return {} + + try: + bot = WxBotClient() + if bot.token: + print(f" {C['green']}✅ 已有有效 token (bot_id={bot.bot_id}){C['reset']}") + if ask_yesno("重新扫码登录?", default=False): + bot.token = '' + else: + return {} + bot.login_qr() + print(f"\n {C['green']}✅ 微信 iLink 扫码登录成功!{C['reset']}") + print(f" Bot ID: {C['bold']}{bot.bot_id}{C['reset']}") + print(f" Token 已保存到: {C['dim']}{bot._tf}{C['reset']}") + except Exception as e: + print(f"\n {C['red']}✗ 扫码登录失败: {e}{C['reset']}") + return {} + + return {} + + # ═══════════════════════════════════════════════════════════════════════════ # 生成 mykey.py @@ -1033,7 +1069,7 @@ def generate_mykey(llm_cfgs, platform_configs): def _write_config_fields(lines, cfg): """写入配置字典的键值对(缩进的 'key': value, 格式)""" - for key in ['name', 'apikey', 'apibase', 'model', 'api_mode', + for key in ['name', 'type', 'apikey', 'apibase', 'model', 'api_mode', 'fake_cc_system_prompt', 'thinking_type', 'thinking_budget_tokens', 'reasoning_effort', 'max_tokens', 'max_retries', 'connect_timeout', 'read_timeout', 'temperature', 'context_win', @@ -1066,7 +1102,7 @@ def _write_platform_value(lines, key, val): def _parse_existing_mykey(): """解析已有 mykey.py,返回 (model_names, platform_infos) - llm_cfgs: [{'name': str, 'type': str, ...}] — 模型配置字典列表 + model_names: [str] — 模型名列表 platform_infos: [{'id': str, 'vars': [{'key': str, 'val': ...}]}] — 平台信息 解析失败时返回 ([], []) """ @@ -1082,21 +1118,81 @@ def _parse_existing_mykey(): if m: model_names = re.findall(r"'([^']+)'", m.group(1)) - # 解析平台变量 → 平台 ID - platform_id_map = { - 'tg_bot_token': 'telegram', 'qq_app_id': 'qq', - 'fs_app_id': 'feishu', 'wecom_bot_id': 'wecom', - 'dingtalk_client_id': 'dingtalk', 'dc_bot_token': 'discord', - } + # 先收集所有已知平台 env var key → 判断值类型 + all_env_var_keys = {} + platform_env_keys = {} # pid -> [var_key] + for p in PLATFORMS: + pid = p['id'] + platform_env_keys.setdefault(pid, []) + for var in p.get('env_vars', []): + vkey = var['key'] + all_env_var_keys[vkey] = var + platform_env_keys[pid].append(vkey) + + # 逐平台解析所有已知变量 platform_infos = [] - for var_key, pid in platform_id_map.items(): - m_var = re.search(rf"^{var_key}\s*=\s*'([^']*)'", content, re.MULTILINE) - if m_var: - platform_infos.append({'id': pid, 'vars': [{'key': var_key, 'val': m_var.group(1)}]}) + for pid, env_keys in platform_env_keys.items(): + vars_found = [] + for vkey in env_keys: + var_def = all_env_var_keys[vkey] + val = None + if var_def.get('is_list'): + # 匹配 `xxx = [...]` + m_var = re.search(rf"^{vkey}\s*=\s*(\[[^\]]*\])", content, re.MULTILINE) + if m_var: + try: + val = ast.literal_eval(m_var.group(1)) + except (ValueError, SyntaxError): + pass + else: + # 匹配 `xxx = '...'` + m_var = re.search(rf"^{vkey}\s*=\s*'([^']*)'", content, re.MULTILINE) + if m_var: + val = m_var.group(1) + if val is not None: + vars_found.append({'key': vkey, 'val': val}) + if vars_found: + platform_infos.append({'id': pid, 'vars': vars_found}) return model_names, platform_infos +def _parse_existing_llm_cfgs(): + """解析已有 mykey.py,返回完整 LLM 配置字典列表 [{name, apikey, ...}] + 解析失败时返回 [] + """ + if not os.path.exists(MYKPY_PATH): + return [] + + with open(MYKPY_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + cfgs = [] + # 匹配所有 `xxx = { ... }` 顶层字典赋值 + # 用简单状态机: 找 `\w+ = {` 然后匹配花括号 + pattern = re.compile(r'^(\w+)\s*=\s*\{', re.MULTILINE) + for m in pattern.finditer(content): + brace_start = m.end() - 1 # '{' 的位置 + depth = 1 + i = brace_start + 1 + while i < len(content) and depth > 0: + if content[i] == '{': + depth += 1 + elif content[i] == '}': + depth -= 1 + i += 1 + if depth == 0: + dict_text = content[m.end():i - 1] + try: + d = ast.literal_eval('{' + dict_text + '}') + if isinstance(d, dict) and 'name' in d: + cfgs.append(d) + except (ValueError, SyntaxError): + continue + + return cfgs + + def _backup_with_name(model_names, platform_ids): """按 mykey+模型名+机器人名 格式备份旧 mykey.py""" parts = ['mykey'] @@ -1107,6 +1203,8 @@ def _backup_with_name(model_names, platform_ids): if pid_clean not in parts: parts.append(pid_clean) safe_name = '_'.join(parts) + if safe_name == 'mykey': + safe_name = 'mykey_backup' # 避免和源文件同名 if len(safe_name) > 100: safe_name = safe_name[:100] backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py') @@ -1154,7 +1252,7 @@ def main(): if mode == 'new': backup_path = _backup_with_name(model_names, [p['id'] for p in platform_infos]) - print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") + print(f" {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup_path}{C['reset']}") is_new = True else: is_modify = True @@ -1177,8 +1275,12 @@ def main(): config_dict = {v['key']: v['val'] for v in pi['vars']} platform_configs.append({'platform': p, 'config': config_dict}) elif scope == 'platform' and model_names: - print(f"\n {C['yellow']}⚠ 只修改平台时若未提供 LLM 配置将无法使用。{C['reset']}") - cprint(f" 建议两项都重新配置。", 'dim') + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留现有 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") + else: + print(f"\n {C['yellow']}⚠ 保留 LLM 配置失败,将生成空配置。建议两项都重新配置。{C['reset']}") if not is_modify: if is_new: @@ -1210,6 +1312,12 @@ def main(): platform_configs, platform_deps = configure_platforms() if ask_yesno("是否继续配置 LLM 模型?", default=True): llm_cfgs = _do_llm() + elif os.path.exists(MYKPY_PATH): + # 新建+仅平台:从备份保留旧 LLM 配置 + old_cfgs = _parse_existing_llm_cfgs() + if old_cfgs: + llm_cfgs = old_cfgs + print(f"\n {C['green']}✓ 已保留备份中的 LLM 配置: {', '.join(c['name'] for c in old_cfgs)}{C['reset']}") # ── 生成 mykey.py ── if not llm_cfgs and not platform_configs: @@ -1218,10 +1326,9 @@ def main(): content = generate_mykey(llm_cfgs, platform_configs) - # 备份旧文件 - if os.path.exists(MYKPY_PATH): - backup = os.path.join(PROJECT_ROOT, f'mykey.py.bak.{datetime.now().strftime("%Y%m%d_%H%M%S")}') - shutil.copy2(MYKPY_PATH, backup) + # 备份旧文件(修改模式不备份,直接在原文件修改) + if os.path.exists(MYKPY_PATH) and not is_modify and not is_new: + backup = _backup_with_name(model_names, [p['id'] for p in platform_infos]) print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") # 写入 diff --git a/frontends/wechatapp.py b/frontends/wechatapp.py index 3c711014a..aff57bf16 100644 --- a/frontends/wechatapp.py +++ b/frontends/wechatapp.py @@ -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 @@ _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 @@ def login_qr(self, poll_interval=2): 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 @@ def get_updates(self, timeout=30): 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 @@ def run_loop(self, on_message, poll_timeout=30): 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 _send(show): _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 _send(show): 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) \ No newline at end of file + try: + bot.run_loop(on_message) + except AuthExpired: + print('[Bot] token expired, exit.', file=sys.__stdout__) + sys.exit(2) \ No newline at end of file diff --git a/ga_cli/cli.py b/ga_cli/cli.py index 2c7bf4c89..9ecc4455c 100644 --- a/ga_cli/cli.py +++ b/ga_cli/cli.py @@ -65,6 +65,11 @@ def launch_frontend(cmd_parts, args=None): "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 列出所有命令