Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 126 additions & 19 deletions assets/configure_mykey.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
python configure.py
"""

import ast
import os
import sys
import re
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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': ...}]}] — 平台信息
解析失败时返回 ([], [])
"""
Expand All @@ -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']
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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']}")

# 写入
Expand Down
35 changes: 29 additions & 6 deletions frontends/wechatapp.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down Expand Up @@ -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) ──
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
try:
bot.run_loop(on_message)
except AuthExpired:
print('[Bot] token expired, exit.', file=sys.__stdout__)
sys.exit(2)
7 changes: 7 additions & 0 deletions ga_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "启动命令行交互对话模式,最轻量的使用方式",
Expand Down Expand Up @@ -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 列出所有命令
Expand Down