Merge upstream/main with local fsapp.py preserved

This commit is contained in:
Jinyi Han
2026-03-18 12:15:49 +08:00
21 changed files with 774 additions and 743 deletions

5
.gitignore vendored
View File

@@ -60,6 +60,11 @@ memory/*
# Plan SOP # Plan SOP
!memory/plan_sop.md !memory/plan_sop.md
# Skill Search SOP
!memory/skill_search/
!memory/skill_search/**
memory/skill_search/**/__pycache__/
# ADB UI tool # ADB UI tool
!memory/adb_ui.py !memory/adb_ui.py

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 lsdefine
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -226,6 +226,8 @@ The entire core loop is just **92 lines of code** (`agent_loop.py`).
| `web_execute_js` | Control browser behavior | | `web_execute_js` | Control browser behavior |
| `ask_user` | Human-in-the-loop confirmation | | `ask_user` | Human-in-the-loop confirmation |
> Additionally, 2 **memory management tools** (`update_working_checkpoint`, `start_long_term_update`) allow the agent to persist context and accumulate experience across sessions.
4**Capability Extension Mechanism** 4**Capability Extension Mechanism**
> _Capable of dynamically creating new tools._ > _Capable of dynamically creating new tools._
@@ -304,7 +306,7 @@ MIT License — see [LICENSE](LICENSE)
## 📅 最新动态 ## 📅 最新动态
- **2026-03-:** [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7) - **2026-03-10:** [发布百万级 Skill 库](https://mp.weixin.qq.com/s/q2gQ7YvWoiAcwxzaiwpuiQ?scene=1&click_id=7)
- **2026-03-08:** [发布以 GenericAgent 为核心的"政务龙虾" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg) - **2026-03-08:** [发布以 GenericAgent 为核心的"政务龙虾" Dintal Claw](https://mp.weixin.qq.com/s/eiEhwo-j6S-WpLxgBnNxBg)
- **2026-03-01:** [GenericAgent 被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg) - **2026-03-01:** [GenericAgent 被机器之心报道](https://mp.weixin.qq.com/s/uVWpTTF5I1yzAENV_qm7yg)
- **2026-01-11:** GenericAgent V1.0 公开版本发布 - **2026-01-11:** GenericAgent V1.0 公开版本发布
@@ -476,6 +478,8 @@ GenericAgent 通过**分层记忆 × 最小工具集 × 自主执行循环**完
| `web_execute_js` | 控制浏览器行为 | | `web_execute_js` | 控制浏览器行为 |
| `ask_user` | 人机协作确认 | | `ask_user` | 人机协作确认 |
> 此外,还有 2 个**记忆管理工具**`update_working_checkpoint`、`start_long_term_update`),使 Agent 能够跨会话积累经验、维持持久上下文。
4**能力扩展机制** 4**能力扩展机制**
> 具备动态创建新的工具能力 > 具备动态创建新的工具能力
> >

View File

@@ -34,7 +34,7 @@ class Session:
class TMWebDriver: class TMWebDriver:
def __init__(self, host: str = 'localhost', port: int = 18765): def __init__(self, host: str = '127.0.0.1', port: int = 18765):
self.host, self.port = host, port self.host, self.port = host, port
self.sessions, self.results, self.acks = {}, {}, {} self.sessions, self.results, self.acks = {}, {}, {}
self.default_session_id = None self.default_session_id = None
@@ -202,7 +202,7 @@ class TMWebDriver:
hasjump = acked = False hasjump = acked = False
while exec_id not in self.results: while exec_id not in self.results:
time.sleep(0.5) time.sleep(0.2)
if not acked and exec_id in self.acks: if not acked and exec_id in self.acks:
acked = True; start_time = time.time() acked = True; start_time = time.time()
if tp == 'ws': if tp == 'ws':
@@ -266,4 +266,4 @@ class TMWebDriver:
return self.execute_js(f'GM_openInTab("{url}");') return self.execute_js(f'GM_openInTab("{url}");')
if __name__ == "__main__": if __name__ == "__main__":
driver = TMWebDriver(host='localhost', port=18765) driver = TMWebDriver(host='127.0.0.1', port=18765)

View File

@@ -38,23 +38,16 @@ def get_system_prompt():
class GeneraticAgent: class GeneraticAgent:
def __init__(self): def __init__(self):
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
temp_dir = os.path.join(script_dir, 'temp') os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
if not os.path.exists(temp_dir): os.makedirs(temp_dir)
from llmcore import mykeys from llmcore import mykeys
llm_sessions = [] llm_sessions = []
for k, cfg in mykeys.items(): for k, cfg in mykeys.items():
if not any(x in k for x in ['api', 'config', 'cookie']): continue if not any(x in k for x in ['api', 'config', 'cookie']): continue
try: try:
if 'claude' in k: llm_sessions += [ClaudeSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'])] if 'claude' in k: llm_sessions += [ClaudeSession(cfg=cfg)]
if 'oai' in k: llm_sessions += [LLMSession( if 'oai' in k: llm_sessions += [LLMSession(cfg=cfg)]
api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model'], proxy=cfg.get('proxy'), if 'xai' in k: llm_sessions += [XaiSession(cfg=cfg)]
api_mode=cfg.get('api_mode', 'chat_completions'), if 'sider' in k: llm_sessions += [SiderLLMSession(cfg={'apikey': cfg, 'model': x}) for x in \
max_retries=cfg.get('max_retries', 2),
connect_timeout=cfg.get('connect_timeout', 10),
read_timeout=cfg.get('read_timeout', 120),
)]
if 'xai' in k: llm_sessions += [XaiSession(cfg, mykeys.get('proxy', ''))]
if 'sider' in k: llm_sessions += [SiderLLMSession(cfg, default_model=x) for x in \
["gemini-3.0-flash", "gpt-5.4"]] ["gemini-3.0-flash", "gpt-5.4"]]
except: pass except: pass
if len(llm_sessions) > 0: self.llmclient = ToolClient(llm_sessions, auto_save_tokens=True) if len(llm_sessions) > 0: self.llmclient = ToolClient(llm_sessions, auto_save_tokens=True)
@@ -63,10 +56,8 @@ class GeneraticAgent:
self.history = [] self.history = []
self.task_queue = queue.Queue() self.task_queue = queue.Queue()
self.is_running, self.stop_sig = False, False self.is_running, self.stop_sig = False, False
self.llm_no = 0 self.llm_no = 0; self.inc_out = False
self.inc_out = False self.handler = None; self.verbose = True
self.handler = None
self.verbose = True
def next_llm(self, n=-1): def next_llm(self, n=-1):
self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclient.backends) self.llm_no = ((self.llm_no + 1) if n < 0 else n) % len(self.llmclient.backends)
@@ -80,8 +71,7 @@ class GeneraticAgent:
print('Abort current task...') print('Abort current task...')
if not self.is_running: return if not self.is_running: return
self.stop_sig = True self.stop_sig = True
if self.handler is not None: if self.handler is not None: self.handler.code_stop_signal.append(1)
self.handler.code_stop_signal.append(1)
def put_task(self, query, source="user", images=None): def put_task(self, query, source="user", images=None):
display_queue = queue.Queue() display_queue = queue.Queue()
@@ -99,10 +89,11 @@ class GeneraticAgent:
sys_prompt = get_system_prompt() sys_prompt = get_system_prompt()
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
handler = GenericAgentHandler(None, self.history, os.path.join(script_dir, 'temp')) handler = GenericAgentHandler(None, self.history, os.path.join(script_dir, 'temp'))
if self.handler and self.handler.key_info: if self.handler and 'key_info' in self.handler.working:
handler.key_info = self.handler.key_info ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧
if '清除工作记忆' not in handler.key_info: handler.working['key_info'] = ki
handler.key_info += '\n[SYSTEM] 若开始新任务,先更新或清除工作记忆\n' handler.working['passed_sessions'] = ps = self.handler.working.get('passed_sessions', 0) + 1
if ps > 0: handler.working['key_info'] += f'\n[SYSTEM] 此为 {ps} 个对话前设置的key_info若已在新任务先更新或清除工作记忆。\n'
self.handler = handler self.handler = handler
self.llmclient.backend = self.llmclient.backends[self.llm_no] self.llmclient.backend = self.llmclient.backends[self.llm_no]
user_input = raw_query user_input = raw_query
@@ -154,7 +145,6 @@ if __name__ == '__main__':
threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=agent.run, daemon=True).start()
if args.task: if args.task:
script_dir = os.path.dirname(os.path.abspath(__file__))
d = os.path.join(script_dir, f'temp/{args.task}'); rp = os.path.join(d, 'reply.txt'); nround = '' d = os.path.join(script_dir, f'temp/{args.task}'); rp = os.path.join(d, 'reply.txt'); nround = ''
with open(os.path.join(d, 'input.txt'), encoding='utf-8') as f: raw = f.read() with open(os.path.join(d, 'input.txt'), encoding='utf-8') as f: raw = f.read()
while True: while True:
@@ -189,11 +179,13 @@ if __name__ == '__main__':
try: try:
while 'done' not in (item := dq.get(timeout=120)): pass while 'done' not in (item := dq.get(timeout=120)): pass
result = item['done'] result = item['done']
print(result)
except Exception as e: except Exception as e:
if once: raise if once: raise
print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}'
script_dir = os.path.dirname(os.path.abspath(__file__)) log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True)
open(os.path.join(script_dir, './temp/reflect.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n') script_name = os.path.splitext(os.path.basename(args.reflect))[0]
open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n')
if on_done: if on_done:
try: on_done(result) try: on_done(result)
except Exception as e: print(f'[Reflect] on_done error: {e}') except Exception as e: print(f'[Reflect] on_done error: {e}')

View File

@@ -1,20 +1,23 @@
# [Global Memory Insight] # [Global Memory Insight]
# 【引导注释 - 使用后请删除整个注释块】 浏览器自动化: web_scan/web_execute_js直接调用 | 特殊:tmwebdriver_sop(文件上传/图搜/PDF blob/元素物理坐标/Cookie提取含HttpOnly/跨域iframe操控/CDP/跨tab/后台tab操作)
# 格式:两层「场景→记忆」映射 + RULES总计≤30行 键鼠模拟: ljqCtrl_sop+.py(仅win禁pyautogui/先activate窗口)
# 定时任务: scheduled_task_sop(报告→sche_tasks/done/) | 与自主任务完全独立
# == 第一层:高频场景 key→value == 自主探索任务: autonomous_operation_sop(报告→temp/autonomous_reports/history.txt不在memory下) | 与定时任务完全独立
# 每行格式: 触发场景关键词: sop名/py名/L2:section名(关键参数) 手机操控: adb_ui.py
# 示例: 境外网站/API: proxies={'https':'http://127.0.0.1:2082'}
# 示例: 微信发消息: wechat_send_sop | 微信读聊天: wechat_db_sop+wechat_db_utils(quick_connect)
# 可用 | 分隔同类场景,括号内放硬参数
#
# == 第二层:低频关键词,自包含可只写一个词 ==
# 格式: 一行内用 | 分隔多个关键词,需要时 read L2 或 ls memory/ 查详情
# 示例: 邮件ezgmail | 游戏game | OCR | 华为云Huawei_Cloud
#
# == [RULES] ==
# 压缩的通用操作准则,每条一行
# 示例: 1. 搜索先行: 文件用es, 信息用google, 禁猜路径
需要时read L2 或 ls ../memory/ 查L3
L0(META-SOP): memory_management_sop L0(META-SOP): memory_management_sop
出厂L3: web_setup_sop | autonomous_operation_sop | scheduled_task_sop | ljqCtrl_sop+.py | tmwebdriver_sop | subagent_sop | plan_sop | mem_scanner.py | adb_ui.py L2: 现空
L3: web_setup_sop | autonomous_operation_sop | scheduled_task_sop | ljqCtrl_sop+.py | tmwebdriver_sop | subagent_sop | plan_sop | mem_scanner.py | adb_ui.py
[RULES]
1. 搜索先行: 信息尽量用google必须web, 项目内os.listdir, 禁猜路径
2. 交叉验证: 禁信搜索摘要, 数值必进详情页核实
3. 编码安全: 改前必读源码; import memory用sys.path.append
4. 闭环: 物理模拟后必确认; 3次失败请求干预;
5. 进程: 禁无条件杀python(会杀自己), 精确PID, 禁os.kill判活
6. 窗口: GUI状态优先枚举窗口, 比OCR快
7. 物理红线: cwd用./; cwd指定后代码内禁用../向上切换,改用绝对路径
8. web JS: 一次写对输入用原生setter+事件链点击前检查disabled注意引号转义; scan空再scan或innerText
9. SOP: 执行前读取缓存硬参数,禁凭印象,有utils必用; 复杂长程先读plan_sop
10. 用户提及或复杂长程需规划任务要读plan_sop进入规划模式

View File

@@ -11,7 +11,7 @@
// @grant GM_xmlhttpRequest // @grant GM_xmlhttpRequest
// @grant GM_openInTab // @grant GM_openInTab
// @grant unsafeWindow // @grant unsafeWindow
// @connect localhost // @connect 127.0.0.1
// @run-at document-start // @run-at document-start
// ==/UserScript== // ==/UserScript==
@@ -26,13 +26,13 @@
return; return;
} }
const wsUrl = 'ws://localhost:18765'; const wsUrl = 'ws://127.0.0.1:18765';
const httpUrl = 'http://localhost:18766/'; const httpUrl = 'http://127.0.0.1:18766/';
function isWebSocketServerAlive(callback) { function isWebSocketServerAlive(callback) {
GM_xmlhttpRequest({ GM_xmlhttpRequest({
method: 'GET', method: 'GET',
url: 'http://localhost:18765/', url: 'http://127.0.0.1:18765/',
onload: () => callback(true), onload: () => callback(true),
onerror: () => callback(false) onerror: () => callback(false)
}); });

186
chatapp_common.py Normal file
View File

@@ -0,0 +1,186 @@
import asyncio, glob, os, queue as Q, re, socket, sys, time
HELP_TEXT = "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型"
FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
def clean_reply(text):
for pat in TAG_PATS:
text = re.sub(pat, "", text or "", flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
def extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def split_text(text, limit):
text, parts = (text or "").strip() or "...", []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit * 0.6:
cut = limit
parts.append(text[:cut].rstrip())
text = text[cut:].lstrip()
return parts + ([text] if text else []) or ["..."]
def format_restore():
files = glob.glob("./temp/model_responses_*.txt")
if not files:
return None, "❌ 没有找到历史记录"
latest = max(files, key=os.path.getmtime)
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
restored = []
for u, r in zip(users, resps):
u, r = u.strip(), r.strip()[:500]
if u and r:
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
if not restored:
return None, "❌ 历史记录里没有可恢复内容"
return (restored, os.path.basename(latest), len(restored) // 2), None
def build_done_text(raw_text):
files = [p for p in extract_files(raw_text) if os.path.exists(p)]
body = strip_files(clean_reply(raw_text))
if files:
body = (body + "\n\n" if body else "") + "\n".join(f"生成文件: {p}" for p in files)
return body or "..."
def public_access(allowed):
return not allowed or "*" in allowed
def to_allowed_set(value):
if value is None:
return set()
if isinstance(value, str):
value = [value]
return {str(x).strip() for x in value if str(x).strip()}
def allowed_label(allowed):
return "public" if public_access(allowed) else sorted(allowed)
def ensure_single_instance(port, label):
try:
lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lock_sock.bind(("127.0.0.1", port))
return lock_sock
except OSError:
print(f"[{label}] Another instance is already running, skipping...")
sys.exit(1)
def require_runtime(agent, label, **required):
missing = [k for k, v in required.items() if not v]
if missing:
print(f"[{label}] ERROR: please set {', '.join(missing)} in mykey.py or mykey.json")
sys.exit(1)
if agent.llmclient is None:
print(f"[{label}] ERROR: no usable LLM backend found in mykey.py or mykey.json")
sys.exit(1)
def redirect_log(script_file, log_name, label, allowed):
log_dir = os.path.join(os.path.dirname(script_file), "temp")
os.makedirs(log_dir, exist_ok=True)
logf = open(os.path.join(log_dir, log_name), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = logf
print(f"[NEW] {label} process starting, the above are history infos ...")
print(f"[{label}] allow list: {allowed_label(allowed)}")
class AgentChatMixin:
label = "Chat"
source = "chat"
split_limit = 1500
ping_interval = 20
def __init__(self, agent, user_tasks):
self.agent, self.user_tasks = agent, user_tasks
async def send_text(self, chat_id, content, **ctx):
raise NotImplementedError
async def send_done(self, chat_id, raw_text, **ctx):
await self.send_text(chat_id, build_done_text(raw_text), **ctx)
async def handle_command(self, chat_id, cmd, **ctx):
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/stop":
state = self.user_tasks.get(chat_id)
if state:
state["running"] = False
self.agent.abort()
return await self.send_text(chat_id, "⏹️ 正在停止...", **ctx)
if op == "/status":
llm = self.agent.get_llm_name() if self.agent.llmclient else "未配置"
return await self.send_text(chat_id, f"状态: {'🔴 运行中' if self.agent.is_running else '🟢 空闲'}\nLLM: [{self.agent.llm_no}] {llm}", **ctx)
if op == "/llm":
if not self.agent.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", **ctx)
if len(parts) > 1:
try:
self.agent.next_llm(int(parts[1]))
return await self.send_text(chat_id, f"✅ 已切换到 [{self.agent.llm_no}] {self.agent.get_llm_name()}", **ctx)
except Exception:
return await self.send_text(chat_id, f"用法: /llm <0-{len(self.agent.list_llms()) - 1}>", **ctx)
lines = [f"{'' if cur else ' '} [{i}] {name}" for i, name, cur in self.agent.list_llms()]
return await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), **ctx)
if op == "/restore":
try:
restored_info, err = format_restore()
if err:
return await self.send_text(chat_id, err, **ctx)
restored, fname, count = restored_info
self.agent.abort()
self.agent.history.extend(restored)
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
except Exception as e:
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
if op == "/new":
self.agent.abort()
self.agent.history = []
return await self.send_text(chat_id, "🆕 已清空当前共享上下文", **ctx)
return await self.send_text(chat_id, HELP_TEXT, **ctx)
async def run_agent(self, chat_id, text, **ctx):
state = {"running": True}
self.user_tasks[chat_id] = state
try:
await self.send_text(chat_id, "思考中...", **ctx)
dq = self.agent.put_task(f"{FILE_HINT}\n\n{text}", source=self.source)
last_ping = time.time()
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if self.agent.is_running and time.time() - last_ping > self.ping_interval:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", **ctx)
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""), **ctx)
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止", **ctx)
except Exception as e:
import traceback
print(f"[{self.label}] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}", **ctx)
finally:
self.user_tasks.pop(chat_id, None)

View File

@@ -1,8 +1,9 @@
import os, sys, re, threading, asyncio, queue as Q, socket, time, glob, json import asyncio, json, os, sys, threading, time
import requests import requests
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
from llmcore import mykeys from llmcore import mykeys
try: try:
@@ -12,82 +13,26 @@ except Exception:
print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream") print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
sys.exit(1) sys.exit(1)
agent = GeneraticAgent() agent = GeneraticAgent(); agent.verbose = False
agent.verbose = False
CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip() CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") or "").strip()
CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip() CLIENT_SECRET = str(mykeys.get("dingtalk_client_secret", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()} ALLOWED = {str(x).strip() for x in mykeys.get("dingtalk_allowed_users", []) if str(x).strip()}
USER_TASKS = {}
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
_USER_TASKS = {}
def _clean(text): class DingTalkApp(AgentChatMixin):
for pat in _TAG_PATS: label, source, split_limit = "DingTalk", "dingtalk", 1800
text = re.sub(pat, "", text, flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
def _extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def _strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def _split_text(text, limit=1800):
text = (text or "").strip() or "..."
parts = []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit * 0.6:
cut = limit
parts.append(text[:cut].rstrip())
text = text[cut:].lstrip()
if text:
parts.append(text)
return parts or ["..."]
def _format_restore():
files = glob.glob("./temp/model_responses_*.txt")
if not files:
return None, "❌ 没有找到历史记录"
latest = max(files, key=os.path.getmtime)
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
count, restored = 0, []
for u, r in zip(users, resps):
u, r = u.strip(), r.strip()[:500]
if u and r:
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
count += 1
if not restored:
return None, "❌ 历史记录里没有可恢复内容"
return (restored, os.path.basename(latest), count), None
class DingTalkApp:
def __init__(self): def __init__(self):
self.client = None super().__init__(agent, USER_TASKS)
self.access_token = None self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
self.token_expiry = 0
self.background_tasks = set()
async def _get_access_token(self): async def _get_access_token(self):
if self.access_token and time.time() < self.token_expiry: if self.access_token and time.time() < self.token_expiry:
return self.access_token return self.access_token
def _fetch(): def _fetch():
resp = requests.post( resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET},
timeout=20,
)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
@@ -107,30 +52,17 @@ class DingTalkApp:
headers = {"x-acs-dingtalk-access-token": token} headers = {"x-acs-dingtalk-access-token": token}
if chat_id.startswith("group:"): if chat_id.startswith("group:"):
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send" url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
payload = { payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
"robotCode": CLIENT_ID,
"openConversationId": chat_id[6:],
"msgKey": msg_key,
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
else: else:
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
payload = { payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
"robotCode": CLIENT_ID,
"userIds": [chat_id],
"msgKey": msg_key,
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
def _post(): def _post():
resp = requests.post(url, json=payload, headers=headers, timeout=20) resp = requests.post(url, json=payload, headers=headers, timeout=20)
body = resp.text body = resp.text
if resp.status_code != 200: if resp.status_code != 200:
raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}") raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
try: result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
result = resp.json()
except Exception:
result = {}
errcode = result.get("errcode") errcode = result.get("errcode")
if errcode not in (None, 0): if errcode not in (None, 0):
raise RuntimeError(f"API errcode={errcode}: {body[:300]}") raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
@@ -143,119 +75,32 @@ class DingTalkApp:
return False return False
async def send_text(self, chat_id, content): async def send_text(self, chat_id, content):
for part in _split_text(content): for part in split_text(content, self.split_limit):
await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"}) await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
async def send_done(self, chat_id, raw_text):
files = [p for p in _extract_files(raw_text) if os.path.exists(p)]
body = _strip_files(_clean(raw_text))
if files:
body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files])
await self.send_text(chat_id, body or "...")
async def handle_command(self, chat_id, cmd):
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/stop":
state = _USER_TASKS.get(chat_id)
if state:
state["running"] = False
agent.abort()
await self.send_text(chat_id, "⏹️ 正在停止...")
elif op == "/status":
llm = agent.get_llm_name() if agent.llmclient else "未配置"
await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}")
elif op == "/llm":
if not agent.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置")
if len(parts) > 1:
try:
n = int(parts[1])
agent.next_llm(n)
await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}")
except Exception:
await self.send_text(chat_id, 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 self.send_text(chat_id, "LLMs:\n" + "\n".join(lines))
elif op == "/restore":
try:
restored_info, err = _format_restore()
if err:
return await self.send_text(chat_id, err)
restored, fname, count = restored_info
agent.abort()
agent.history.extend(restored)
await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
except Exception as e:
await self.send_text(chat_id, f"❌ 恢复失败: {e}")
elif op == "/new":
agent.abort()
agent.history = []
await self.send_text(chat_id, "🆕 已清空当前共享上下文")
else:
await self.send_text(
chat_id,
"📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型",
)
async def run_agent(self, chat_id, text):
state = {"running": True}
_USER_TASKS[chat_id] = state
try:
await self.send_text(chat_id, "思考中...")
prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}"
dq = agent.put_task(prompt, source="dingtalk")
last_ping = time.time()
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if agent.is_running and time.time() - last_ping > 20:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...")
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""))
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止")
except Exception as e:
import traceback
print(f"[DingTalk] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}")
finally:
_USER_TASKS.pop(chat_id, None)
async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None): async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
try: try:
if not content: if not content:
return return
public_access = not ALLOWED or "*" in ALLOWED if not public_access(ALLOWED) and sender_id not in ALLOWED:
if not public_access and sender_id not in ALLOWED:
print(f"[DingTalk] unauthorized user: {sender_id}") print(f"[DingTalk] unauthorized user: {sender_id}")
return return
is_group = conversation_type == "2" and conversation_id is_group = conversation_type == "2" and conversation_id
chat_id = f"group:{conversation_id}" if is_group else sender_id chat_id = f"group:{conversation_id}" if is_group else sender_id
print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}") print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
if content.startswith("/"): if content.startswith("/"):
await self.handle_command(chat_id, content) return await self.handle_command(chat_id, content)
return
task = asyncio.create_task(self.run_agent(chat_id, content)) task = asyncio.create_task(self.run_agent(chat_id, content))
self.background_tasks.add(task) self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard) task.add_done_callback(self.background_tasks.discard)
except Exception: except Exception:
import traceback import traceback
print("[DingTalk] handle_message error") print("[DingTalk] handle_message error")
traceback.print_exc() traceback.print_exc()
async def start(self): async def start(self):
handler = _DingTalkHandler(self)
self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET)) self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
self.client.register_callback_handler(ChatbotMessage.TOPIC, handler) self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
print("[DingTalk] bot starting...") print("[DingTalk] bot starting...")
while True: while True:
try: try:
@@ -274,44 +119,22 @@ class _DingTalkHandler(CallbackHandler):
async def process(self, message): async def process(self, message):
try: try:
chatbot_msg = ChatbotMessage.from_dict(message.data) chatbot_msg = ChatbotMessage.from_dict(message.data)
text = "" text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
if getattr(getattr(chatbot_msg, "text", None), "content", None):
text = chatbot_msg.text.content.strip()
extensions = getattr(chatbot_msg, "extensions", None) or {} extensions = getattr(chatbot_msg, "extensions", None) or {}
recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else "" recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
if not text: if not (text := text.strip()):
text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip() text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
sender_id = getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown" sender_id = str(getattr(chatbot_msg, "sender_staff_id", None) or getattr(chatbot_msg, "sender_id", None) or "unknown")
sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown" sender_name = getattr(chatbot_msg, "sender_nick", None) or "Unknown"
conversation_type = message.data.get("conversationType") await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
conversation_id = message.data.get("conversationId") or message.data.get("openConversationId")
await self.app.on_message(text, str(sender_id), sender_name, conversation_type, conversation_id)
return AckMessage.STATUS_OK, "OK"
except Exception as e: except Exception as e:
print(f"[DingTalk] callback error: {e}") print(f"[DingTalk] callback error: {e}")
return AckMessage.STATUS_OK, "Error" return AckMessage.STATUS_OK, "OK"
if __name__ == "__main__": if __name__ == "__main__":
try: _LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
_lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
_lock_sock.bind(("127.0.0.1", 19530)) redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
except OSError:
print("[DingTalk] Another instance is already running, skipping...")
sys.exit(1)
if not CLIENT_ID or not CLIENT_SECRET:
print("[DingTalk] ERROR: please set dingtalk_client_id and dingtalk_client_secret in mykey.py or mykey.json")
sys.exit(1)
if agent.llmclient is None:
print("[DingTalk] ERROR: no usable LLM backend found in mykey.py or mykey.json")
sys.exit(1)
log_dir = os.path.join(os.path.dirname(__file__), "temp")
os.makedirs(log_dir, exist_ok=True)
_logf = open(os.path.join(log_dir, "dingtalkapp.log"), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = _logf
print("[NEW] DingTalk process starting, the above are history infos ...")
print(f"[DingTalk] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}")
threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=agent.run, daemon=True).start()
asyncio.run(DingTalkApp().start()) asyncio.run(DingTalkApp().start())

18
ga.py
View File

@@ -246,8 +246,7 @@ class GenericAgentHandler(BaseHandler):
'''Generic Agent 工具库,包含多种工具的实现。工具函数自动加上了 do_ 前缀。实际工具名没有前缀。''' '''Generic Agent 工具库,包含多种工具的实现。工具函数自动加上了 do_ 前缀。实际工具名没有前缀。'''
def __init__(self, parent, last_history=None, cwd='./'): def __init__(self, parent, last_history=None, cwd='./'):
self.parent = parent self.parent = parent
self.key_info = "" self.working = {}
self.related_sop = ""
self.cwd = cwd; self.current_turn = 0 self.cwd = cwd; self.current_turn = 0
self.history_info = last_history if last_history else [] self.history_info = last_history if last_history else []
self.code_stop_signal = [] self.code_stop_signal = []
@@ -408,11 +407,12 @@ class GenericAgentHandler(BaseHandler):
''' '''
key_info = args.get("key_info", "") key_info = args.get("key_info", "")
related_sop = args.get("related_sop", "") related_sop = args.get("related_sop", "")
if "key_info" in args: self.key_info = key_info if "key_info" in args: self.working['key_info'] = key_info
if "related_sop" in args: self.related_sop = related_sop if "related_sop" in args: self.working['related_sop'] = related_sop
self.working['passed_sessions'] = 0
yield f"[Info] Updated key_info and related_sop.\n" yield f"[Info] Updated key_info and related_sop.\n"
yield f"key_info:\n{self.key_info}\n\n" yield f"key_info:\n{self.working.get('key_info', '')}\n\n"
yield f"related_sop:\n{self.related_sop}\n\n" yield f"related_sop:\n{self.working.get('related_sop', '')}\n\n"
next_prompt = self._get_anchor_prompt() next_prompt = self._get_anchor_prompt()
#next_prompt += '\n[SYSTEM TIPS] 此函数一般在任务开始或中间时调用如果任务已成功完成应该是start_long_term_update用于结算长期记忆。\n' #next_prompt += '\n[SYSTEM TIPS] 此函数一般在任务开始或中间时调用如果任务已成功完成应该是start_long_term_update用于结算长期记忆。\n'
return StepOutcome({"status": "success"}, next_prompt=next_prompt) return StepOutcome({"status": "success"}, next_prompt=next_prompt)
@@ -477,14 +477,14 @@ class GenericAgentHandler(BaseHandler):
h_str = "\n".join(self.history_info[-20:]) h_str = "\n".join(self.history_info[-20:])
prompt = f"\n### [WORKING MEMORY]\n<history>\n{h_str}\n</history>" prompt = f"\n### [WORKING MEMORY]\n<history>\n{h_str}\n</history>"
prompt += f"\nCurrent turn: {self.current_turn}\n" prompt += f"\nCurrent turn: {self.current_turn}\n"
if self.key_info: prompt += f"\n<key_info>{self.key_info}</key_info>" if self.working.get('key_info'): prompt += f"\n<key_info>{self.working.get('key_info')}</key_info>"
if self.related_sop: prompt += f"\n有不清晰的地方请再次读取{self.related_sop}" if self.working.get('related_sop'): prompt += f"\n有不清晰的地方请再次读取{self.working.get('related_sop')}"
try: print(prompt) try: print(prompt)
except: pass except: pass
return prompt return prompt
def next_prompt_patcher(self, next_prompt, outcome, turn): def next_prompt_patcher(self, next_prompt, outcome, turn):
if turn % 35 == 0 and 'plan' not in str(self.related_sop): if turn % 35 == 0 and 'plan' not in str(self.working.get('related_sop')):
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。你必须总结情况进行ask_user不允许继续重试。" next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。你必须总结情况进行ask_user不允许继续重试。"
elif turn % 7 == 0: elif turn % 7 == 0:
next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展必须切换策略1. 探测物理边界 2. 请求用户协助。如有需要,可调用 update_working_checkpoint 保存关键上下文。" next_prompt += f"\n\n[DANGER] 已连续执行第 {turn} 轮。禁止无效重试。若无有效进展必须切换策略1. 探测物理边界 2. 请求用户协助。如有需要,可调用 update_working_checkpoint 保存关键上下文。"

View File

@@ -54,10 +54,10 @@ def build_multimodal_content(prompt_text, image_paths):
return parts return parts
class SiderLLMSession: class SiderLLMSession:
def __init__(self, sider_cookie, default_model="gemini-3.0-flash"): def __init__(self, cfg):
from sider_ai_api import Session # 不使用sider的话没必要安装这个包 from sider_ai_api import Session # 不使用sider的话没必要安装这个包
self._core = Session(cookie=sider_cookie, proxies=proxies) self._core = Session(cookie=cfg['apikey'], proxies=proxies)
self.default_model = default_model self.default_model = cfg.get('model', 'gemini-3.0-flash')
def ask(self, prompt, model=None, stream=False): def ask(self, prompt, model=None, stream=False):
if model is None: model = self.default_model if model is None: model = self.default_model
if len(prompt) > 28000: if len(prompt) > 28000:
@@ -68,8 +68,10 @@ class SiderLLMSession:
return full_text return full_text
class ClaudeSession: class ClaudeSession:
def __init__(self, api_key, api_base, model="claude-opus", context_win=12000): def __init__(self, cfg):
self.api_key, self.api_base, self.default_model, self.context_win = api_key, api_base.rstrip('/'), model, context_win self.api_key = cfg['apikey']; self.api_base = cfg['apibase'].rstrip('/')
self.default_model = cfg.get('model', 'claude-opus')
self.context_win = cfg.get('context_win', 12000)
self.raw_msgs, self.lock = [], threading.Lock() self.raw_msgs, self.lock = [], threading.Lock()
def _trim_messages(self, messages): def _trim_messages(self, messages):
compress_history_tags(messages) compress_history_tags(messages)
@@ -84,6 +86,7 @@ class ClaudeSession:
return result[::-1] or messages[-2:] return result[::-1] or messages[-2:]
def raw_ask(self, messages, model=None, temperature=0.5, max_tokens=6144): def raw_ask(self, messages, model=None, temperature=0.5, max_tokens=6144):
model = model or self.default_model model = model or self.default_model
if 'kimi' in model.lower() or 'moonshot' in model.lower(): temperature = 1.0 # kimi/moonshot only accepts temp 1.0
headers = {"x-api-key": self.api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01"} headers = {"x-api-key": self.api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01"}
payload = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, "stream": True} payload = {"model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, "stream": True}
try: try:
@@ -117,28 +120,28 @@ class ClaudeSession:
return _ask_gen() if stream else ''.join(list(_ask_gen())) return _ask_gen() if stream else ''.join(list(_ask_gen()))
class LLMSession: class LLMSession:
def __init__(self, api_key, api_base, model, context_win=16000, proxy=None, api_mode="chat_completions", def __init__(self, cfg):
max_retries=2, connect_timeout=10, read_timeout=120): self.api_key = cfg['apikey']; self.api_base = cfg['apibase'].rstrip('/')
self.api_key = api_key; self.api_base = api_base.rstrip('/'); self.default_model = model self.default_model = cfg['model']
self.context_win = context_win; self.raw_msgs = []; self.messages = [] self.context_win = cfg.get('context_win', 16000)
self.raw_msgs, self.messages = [], []
proxy = cfg.get('proxy')
self.proxies = {"http": proxy, "https": proxy} if proxy else None self.proxies = {"http": proxy, "https": proxy} if proxy else None
self.prompt_cache = cfg.get('prompt_cache', False)
self.lock = threading.Lock() self.lock = threading.Lock()
self.max_retries = max(0, int(max_retries)) self.max_retries = max(0, int(cfg.get('max_retries', 2)))
self.connect_timeout = max(1, int(connect_timeout)) self.connect_timeout = max(1, int(cfg.get('connect_timeout', 10)))
self.read_timeout = max(5, int(read_timeout)) self.read_timeout = max(5, int(cfg.get('read_timeout', 120)))
mode = str(api_mode or "chat_completions").strip().lower().replace('-', '_') mode = str(cfg.get('api_mode', 'chat_completions')).strip().lower().replace('-', '_')
if mode in ["responses", "response"]: self.api_mode = "responses" if mode in ["responses", "response"]: self.api_mode = "responses"
else: self.api_mode = "chat_completions" else: self.api_mode = "chat_completions"
def _retry_delay(self, resp, attempt): def _retry_delay(self, resp, attempt):
retry_after = None retry_after = None
try: try:
if resp is not None: if resp is not None: retry_after = (resp.headers or {}).get("retry-after")
retry_after = (resp.headers or {}).get("retry-after") if retry_after is not None: retry_after = float(retry_after)
if retry_after is not None: except: retry_after = None
retry_after = float(retry_after)
except:
retry_after = None
if retry_after is None: retry_after = min(30.0, 1.5 * (2 ** attempt)) if retry_after is None: retry_after = min(30.0, 1.5 * (2 ** attempt))
return max(0.5, float(retry_after)) return max(0.5, float(retry_after))
@@ -168,6 +171,7 @@ class LLMSession:
def raw_ask(self, messages, model=None, temperature=0.5): def raw_ask(self, messages, model=None, temperature=0.5):
if model is None: model = self.default_model if model is None: model = self.default_model
if 'kimi' in model.lower() or 'moonshot' in model.lower(): temperature = 1.0 # kimi/moonshot only accepts temp 1.0
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"} headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", "Accept": "text/event-stream"}
if self.api_mode == "responses": if self.api_mode == "responses":
url = auto_make_url(self.api_base, "responses") url = auto_make_url(self.api_base, "responses")
@@ -310,7 +314,7 @@ class LLMSession:
content += chunk; yield chunk content += chunk; yield chunk
if not content.startswith("Error:"): if not content.startswith("Error:"):
self.raw_msgs.append({"role": "assistant", "prompt": content, "image": None}) self.raw_msgs.append({"role": "assistant", "prompt": content, "image": None})
if total_len > 5000: print(f"[Debug] Whole context length {total_len} {str(msg_lens)}.") if total_len > self.context_win // 2: print(f"[Debug] Whole context length {total_len} {str(msg_lens)}.")
if total_len > self.context_win: if total_len > self.context_win:
yield '[NextWillSummary]' yield '[NextWillSummary]'
threading.Thread(target=self.summary_history, daemon=True).start() threading.Thread(target=self.summary_history, daemon=True).start()
@@ -319,11 +323,12 @@ class LLMSession:
class GeminiSession: class GeminiSession:
def __init__(self, api_key=None, default_model="gemini-2.0-flash-001", proxy=proxy): def __init__(self, cfg):
self.api_key = api_key or google_api_key self.api_key = cfg.get('apikey') or google_api_key
if not self.api_key: raise ValueError("google_api_key 未配置或为空,请在 mykey.py 中设置") if not self.api_key: raise ValueError("google_api_key 未配置或为空,请在 mykey.py 中设置")
self.default_model = default_model self.default_model = cfg.get('model', 'gemini-2.0-flash-001')
self.proxies = {"http":proxy, "https":proxy} if proxy else None p = cfg.get('proxy', proxy)
self.proxies = {"http":p, "https":p} if p else None
def ask(self, prompt, model=None, stream=False): def ask(self, prompt, model=None, stream=False):
if model is None: model = self.default_model if model is None: model = self.default_model
url = f"https://generativelanguage.googleapis.com/v1/models/{model}:generateContent?key={self.api_key}" url = f"https://generativelanguage.googleapis.com/v1/models/{model}:generateContent?key={self.api_key}"
@@ -347,13 +352,14 @@ class GeminiSession:
return iter([full_text]) if stream else full_text return iter([full_text]) if stream else full_text
class XaiSession: class XaiSession:
def __init__(self, api_key, proxy="http://127.0.0.1:2082", default_model="grok-4-1-fast-non-reasoning"): def __init__(self, cfg):
import xai_sdk import xai_sdk
from xai_sdk.chat import user, system from xai_sdk.chat import user, system
self._user, self._system = user, system self._user, self._system = user, system
self.default_model = default_model self.default_model = cfg.get('model', 'grok-4-1-fast-non-reasoning')
self._last_response_id = None # 多轮对话链 self._last_response_id = None # 多轮对话链
os.environ["XAI_API_KEY"] = api_key os.environ["XAI_API_KEY"] = cfg['apikey']
proxy = cfg.get('proxy', 'http://127.0.0.1:2082')
if not proxy.startswith("http"): proxy = f"http://{proxy}" if not proxy.startswith("http"): proxy = f"http://{proxy}"
os.environ.setdefault("grpc_proxy", proxy) os.environ.setdefault("grpc_proxy", proxy)
self._client = xai_sdk.Client() self._client = xai_sdk.Client()
@@ -411,23 +417,28 @@ class ToolClient:
self.total_cd_tokens = 0 self.total_cd_tokens = 0
def chat(self, messages, tools=None): def chat(self, messages, tools=None):
script_dir = os.path.dirname(os.path.abspath(__file__))
log_path = os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt')
if self._should_use_structured_messages(messages): if self._should_use_structured_messages(messages):
return (yield from self._chat_structured(messages, tools)) backend_messages = self._build_backend_messages(messages, tools)
print("Structured prompt length:", sum(self._estimate_content_len(m.get("content")) for m in backend_messages), 'chars')
prompt_log = self._serialize_messages_for_log(backend_messages)
gen = self.backend.raw_ask(backend_messages)
else:
full_prompt = self._build_protocol_prompt(messages, tools) full_prompt = self._build_protocol_prompt(messages, tools)
print("Full prompt length:", len(full_prompt), 'chars') print("Full prompt length:", len(full_prompt), 'chars')
script_dir = os.path.dirname(os.path.abspath(__file__)) prompt_log = full_prompt
with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f:
f.write(f"=== Prompt === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{full_prompt}\n")
gen = self.backend.ask(full_prompt, stream=True) gen = self.backend.ask(full_prompt, stream=True)
with open(log_path, 'a', encoding='utf-8', errors="replace") as f:
f.write(f"=== Prompt === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{prompt_log}\n")
raw_text = ''; summarytag = '[NextWillSummary]' raw_text = ''; summarytag = '[NextWillSummary]'
for chunk in gen: for chunk in gen:
raw_text += chunk; raw_text += chunk
if chunk != summarytag: yield chunk if chunk != summarytag: yield chunk
print('Complete response received.') print('Complete response received.')
if raw_text.endswith(summarytag): if raw_text.endswith(summarytag):
self.last_tools = ''; raw_text = raw_text[:-len(summarytag)] self.last_tools = ''; raw_text = raw_text[:-len(summarytag)]
script_dir = os.path.dirname(os.path.abspath(__file__)) with open(log_path, 'a', encoding='utf-8', errors="replace") as f:
with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f:
f.write(f"=== Response === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{raw_text}\n\n") f.write(f"=== Response === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{raw_text}\n\n")
return self._parse_mixed_response(raw_text) return self._parse_mixed_response(raw_text)
@@ -504,24 +515,6 @@ class ToolClient:
logged.append(msg) logged.append(msg)
return json.dumps(logged, ensure_ascii=False, indent=2) return json.dumps(logged, ensure_ascii=False, indent=2)
def _chat_structured(self, messages, tools):
backend_messages = self._build_backend_messages(messages, tools)
print("Structured prompt length:", sum(self._estimate_content_len(m.get("content")) for m in backend_messages), 'chars')
script_dir = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f:
f.write(f"=== Prompt === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{self._serialize_messages_for_log(backend_messages)}\n")
gen = self.backend.raw_ask(backend_messages)
raw_text = ''; summarytag = '[NextWillSummary]'
for chunk in gen:
raw_text += chunk
if chunk != summarytag: yield chunk
print('Complete response received.')
if raw_text.endswith(summarytag):
self.last_tools = ''; raw_text = raw_text[:-len(summarytag)]
with open(os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt'), 'a', encoding='utf-8', errors="replace") as f:
f.write(f"=== Response === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{raw_text}\n\n")
return self._parse_mixed_response(raw_text)
def _build_protocol_prompt(self, messages, tools): def _build_protocol_prompt(self, messages, tools):
system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "") system_content = next((m['content'] for m in messages if m['role'].lower() == 'system'), "")
history_msgs = [m for m in messages if m['role'].lower() != 'system'] history_msgs = [m for m in messages if m['role'].lower() != 'system']
@@ -609,10 +602,7 @@ if __name__ == "__main__":
} }
google_api_key = mykeys.get("google_api_key") google_api_key = mykeys.get("google_api_key")
cfg = oai_configs.get("oai_config") cfg = oai_configs.get("oai_config")
llmclient = ToolClient(LLMSession(cfg))
llmclient = ToolClient(GeminiSession(api_key=google_api_key, proxy='127.0.0.1:2082').ask)
#llmclient = ToolClient(LLMSession(api_key=cfg['apikey'], api_base=cfg['apibase'], model=cfg['model']).ask)
#llmclient = ToolClient(SiderLLMSession().ask)
def get_final(gen): def get_final(gen):
try: try:
while True: print('mid:', next(gen)) while True: print('mid:', next(gen))

View File

@@ -15,7 +15,7 @@
## 执行 ## 执行
**启动** **启动**
- update_working_checkpoint: `自主行动|报告→./autonomous_reports/R{XX}_简短描述.md≤30回合收尾重读sop写报告+更新history+标记TODO` - update_working_checkpoint: `自主行动|报告→./autonomous_reports/R{XX}_简短描述.md≤30回合收尾重读sop写报告+更新history+标记TODO | 报告严禁放错位置`
-`./autonomous_reports/history.txt` 推断下一编号RXX + 了解历史避免重复 -`./autonomous_reports/history.txt` 推断下一编号RXX + 了解历史避免重复
**执行** **执行**

View File

@@ -0,0 +1,64 @@
# Skill Search — 105K 技能卡检索
> 从 105K+ 技能卡中语义搜索最匹配的 skill。零依赖内置默认 API 地址,开箱即用。
## 最简调用
```python
import sys; sys.path.append('../memory/skill_search')
from skill_search import search
results = search("python send email") # ⚠️ 必须用英文查询,中文匹配效果极差
for r in results:
s = r.skill
print(f"[{r.final_score:.2f}] {s.name}{s.one_line_summary}")
print(f" key: {s.key} category: {s.category} tags: {s.tags[:3]}")
```
## API 签名
```python
search(query, env=None, category=None, top_k=10) -> list[SearchResult]
# env: 自动检测,一般不传
# category: 可选过滤,如 "devops"
# top_k: 返回数量默认10
```
## 返回结构
```
SearchResult
.final_score float 综合评分 (0~1)
.relevance float 语义相关度
.quality float 质量分
.match_reasons list[str] 匹配原因
.warnings list[str] 警告
.skill SkillIndex ↓
SkillIndex (常用字段)
.key str 唯一标识/路径
.name str 名称
.one_line_summary str 一句话摘要
.description str 详细描述
.category str 类别
.tags list[str] 标签
.form str 形式(sop/script/...)
.autonomous_safe bool 是否自主安全
```
## CLI
```bash
python -m skill_search "python testing"
python -m skill_search "docker deployment" --category devops --top 5
python -m skill_search "git" --json
python -m skill_search --stats
python -m skill_search --env
```
## 配置
| 项 | 默认值 | 说明 |
|---|---|---|
| API地址 | `http://www.fudankw.cn:58787` | 环境变量 `SKILL_SEARCH_API` 可覆盖 |
| API密钥 | 无(可选) | 环境变量 `SKILL_SEARCH_KEY` |

View File

@@ -0,0 +1,8 @@
"""skill_search — Skill 检索 API 客户端"""
from .engine import (
SkillIndex, SearchResult, SkillSearchError,
search, get_stats, detect_environment,
)
__all__ = ["SkillIndex", "SearchResult", "SkillSearchError",
"search", "get_stats", "detect_environment"]

View File

@@ -0,0 +1,116 @@
"""CLI 入口: python -m skill_search"""
from __future__ import annotations
import argparse, json, sys
from .engine import SearchResult, SkillSearchError, detect_environment, search, get_stats
# ── 格式化 ───────────────────────────────────────────────
def format_results(results: list[SearchResult], env: dict, query: str) -> str:
lines = [f'🔍 搜索: "{query}"',
f"🖥️ 环境: {env.get('os','?')} / {env.get('shell','?')} / {', '.join(env.get('runtimes',[]))}",
f"📊 找到 {len(results)} 个匹配结果\n"]
if not results:
lines.append("未找到匹配的 skill。试试其他关键词")
return "\n".join(lines)
for i, r in enumerate(results, 1):
s = r.skill
safe_icon = "🟢" if s.autonomous_safe else "🔴"
score_bar = "" * int(r.final_score * 10) + "" * (10 - int(r.final_score * 10))
lines += [
f"{''*60}",
f"#{i} {safe_icon} {s.name}",
f" 路径: {s.key}",
f" 类别: {s.category} | 标签: {', '.join(s.tags[:5])}",
f" 摘要: {s.one_line_summary}",
f" 评分: [{score_bar}] {r.final_score:.2f} (相关={r.relevance:.2f} 质量={r.quality:.1f})",
f" 清晰={s.clarity} 完整={s.completeness} 可操作={s.actionability} | 形式={s.form}",
]
if r.match_reasons:
lines.append(f" 匹配: {' | '.join(r.match_reasons[:3])}")
if r.warnings:
lines.extend(f" {w}" for w in r.warnings)
lines.append("")
lines.append(f"{''*60}")
return "\n".join(lines)
def format_results_json(results: list[SearchResult]) -> list[dict]:
out = []
for r in results:
s = r.skill
out.append({
"rank": len(out) + 1, "key": s.key, "name": s.name,
"category": s.category, "tags": s.tags,
"description": s.description, "one_line_summary": s.one_line_summary,
"scores": {"final": round(r.final_score, 3), "relevance": round(r.relevance, 3),
"quality": round(r.quality, 1), "clarity": s.clarity,
"completeness": s.completeness, "actionability": s.actionability},
"safety": {"autonomous_safe": s.autonomous_safe, "blast_radius": s.blast_radius,
"requires_credentials": s.requires_credentials,
"data_exposure": s.data_exposure, "effect_scope": s.effect_scope},
"platform": {"os": s.os, "runtimes": s.runtimes, "tools": s.tools, "services": s.services},
"warnings": r.warnings, "match_reasons": r.match_reasons,
})
return out
# ── CLI ──────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(prog="skill_search",
description="Skill 检索系统 — 根据环境和需求智能推荐 skillAPI 客户端)")
parser.add_argument("query", nargs="?", help="搜索关键词(如: 'python testing'")
parser.add_argument("--category", "-cat", help="限定类别")
parser.add_argument("--top", "-k", type=int, default=10, help="返回结果数(默认 10")
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
parser.add_argument("--env", action="store_true", help="仅显示检测到的环境信息")
parser.add_argument("--stats", action="store_true", help="显示索引统计信息")
parser.add_argument("--api-url", help="指定 API 地址(也可用 SKILL_SEARCH_API 环境变量)")
args = parser.parse_args()
if args.api_url:
import os; os.environ["SKILL_SEARCH_API"] = args.api_url
env = detect_environment()
if args.env:
print("🖥️ 当前环境:")
print(f" OS: {env['os']}")
print(f" Shell: {env['shell']}")
print(f" 运行时: {', '.join(env['runtimes'])}")
print(f" 工具: {', '.join(env['tools'])}")
print(f" 模型能力: tool_calling={env['model']['tool_calling']}, "
f"reasoning={env['model']['reasoning']}, context={env['model']['context_window']}")
return
if args.stats:
try:
stats = get_stats(env)
print(f"📊 索引统计:")
print(f" 总计: {stats.get('total', '?')} 个 skills")
print(f" 自动安全: {stats.get('safe_count', '?')}")
if 'categories' in stats:
print(f" 类别分布:")
for cat, cnt in sorted(stats['categories'].items(), key=lambda x: -x[1]):
print(f" {cat:15s} {cnt:4d}")
except SkillSearchError as e:
print(f"{e}", file=sys.stderr); sys.exit(1)
return
if not args.query:
parser.print_help(); return
try:
results = search(query=args.query, env=env, category=args.category, top_k=args.top)
except SkillSearchError as e:
print(f"{e}", file=sys.stderr); sys.exit(1)
if args.json:
print(json.dumps(format_results_json(results), indent=2, ensure_ascii=False))
else:
print(format_results(results, env, args.query))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,156 @@
"""Skill 检索引擎 — API 客户端(含数据模型与环境检测)"""
from __future__ import annotations
import json, os, platform, shutil, subprocess, urllib.request, urllib.error
from dataclasses import dataclass, field
# ── 数据模型 ─────────────────────────────────────────────
@dataclass
class SkillIndex:
"""Skill 索引条目(与服务端结构对齐)"""
key: str
name: str = ""
description: str = ""
one_line_summary: str = ""
category: str = ""
tags: list[str] = field(default_factory=list)
language: str = "en"
os: list[str] = field(default_factory=list)
shell: list[str] = field(default_factory=list)
runtimes: list[str] = field(default_factory=list)
tools: list[str] = field(default_factory=list)
services: list[str] = field(default_factory=list)
needs_tool_calling: bool = False
needs_reasoning: bool = False
min_context_window: str = "standard"
decay_risk: str = "low"
clarity: int = 0
completeness: int = 0
actionability: int = 0
autonomous_safe: bool = True
blast_radius: str = "low"
requires_credentials: bool = False
data_exposure: str = "none"
effect_scope: str = "local"
form: str = ""
estimated_tokens: str = "medium"
capabilities: list[str] = field(default_factory=list)
github_stars: int = 0
github_url: str = ""
@property
def quality_score(self):
return self.clarity * 0.3 + self.completeness * 0.3 + self.actionability * 0.4
@classmethod
def from_dict(cls, d):
known = {f.name for f in cls.__dataclass_fields__.values()}
return cls(**{k: v for k, v in d.items() if k in known})
@dataclass
class SearchResult:
"""单条检索结果"""
skill: SkillIndex
relevance: float = 0.0
quality: float = 0.0
final_score: float = 0.0
match_reasons: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
@classmethod
def from_dict(cls, d):
skill = SkillIndex.from_dict(d.get("skill", d))
return cls(skill=skill, relevance=d.get("relevance", 0.0),
quality=d.get("quality", 0.0), final_score=d.get("final_score", 0.0),
match_reasons=d.get("match_reasons", []), warnings=d.get("warnings", []))
# ── 环境检测 ─────────────────────────────────────────────
def _run(cmd):
try:
r = subprocess.run(cmd.split(), capture_output=True, text=True, timeout=5)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def _detect_os():
s = platform.system().lower()
return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, s)
def _detect_shell():
shell = os.environ.get("SHELL", "")
if "zsh" in shell: return "zsh"
if "bash" in shell: return "bash"
if platform.system() == "Windows": return "powershell"
return os.path.basename(shell) if shell else "unknown"
def _detect_runtimes():
checks = {"python": ["python3", "python"], "node": ["node"], "go": ["go"],
"rust": ["rustc"], "java": ["java"], "ruby": ["ruby"],
"php": ["php"], "dotnet": ["dotnet"]}
found = []
for name, cmds in checks.items():
for cmd in cmds:
if shutil.which(cmd):
found.append(name); break
return found
def _detect_tools():
tools = ["git", "docker", "npm", "pip", "curl", "wget", "kubectl",
"terraform", "aws", "gcloud", "az", "brew", "cargo", "make", "cmake"]
return [t for t in tools if shutil.which(t)]
def detect_environment():
"""采集完整环境信息"""
return {"os": _detect_os(), "shell": _detect_shell(),
"runtimes": _detect_runtimes(), "tools": _detect_tools(),
"model": {"tool_calling": True, "reasoning": True, "context_window": "large"}}
# ── API 配置与调用 ────────────────────────────────────────
DEFAULT_API_URL = "http://www.fudankw.cn:58787"
def _get_api_url():
return os.environ.get("SKILL_SEARCH_API", DEFAULT_API_URL)
def _get_api_key():
return os.environ.get("SKILL_SEARCH_KEY")
class SkillSearchError(Exception):
pass
def _api_request(endpoint, payload):
url = f"{_get_api_url()}/{endpoint}"
data = json.dumps(payload).encode("utf-8")
headers = {"Content-Type": "application/json"}
api_key = _get_api_key()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise SkillSearchError(f"API 错误 {e.code}: {body}") from e
except urllib.error.URLError as e:
raise SkillSearchError(f"无法连接服务: {e.reason}") from e
except Exception as e:
raise SkillSearchError(f"请求失败: {e}") from e
# ── 公开接口 ─────────────────────────────────────────────
def search(query, env=None, category=None, top_k=10):
if env is None: env = detect_environment()
payload = {"query": query, "env": env, "top_k": top_k}
if category: payload["category"] = category
resp = _api_request("search", payload)
return [SearchResult.from_dict(r) for r in resp.get("results", [])]
def get_stats(env=None):
if env is None: env = detect_environment()
return _api_request("stats", {"env": env})

View File

@@ -104,4 +104,7 @@ web_scan失败时按序排查
①TM没装→遍历本机所有Chromium浏览器(Chrome/Edge/Brave…)用户数据目录下Extensions/各子目录manifest.json搜"tampermonkey" ①TM没装→遍历本机所有Chromium浏览器(Chrome/Edge/Brave…)用户数据目录下Extensions/各子目录manifest.json搜"tampermonkey"
没找到→走web_setup_sop找到→记住装在哪个浏览器 没找到→走web_setup_sop找到→记住装在哪个浏览器
②浏览器没开?→检查①对应的浏览器进程是否在跑(tasklist/ps)没有则启动并打开正常URL⚠about:blank等内部页不加载扩展 ②浏览器没开?→检查①对应的浏览器进程是否在跑(tasklist/ps)没有则启动并打开正常URL⚠about:blank等内部页不加载扩展
③WS后台挂了→socket.connect_ex(('localhost',18766))非0即dead→手动`from TMWebDriver import TMWebDriver; TMWebDriver()`起master ③WS后台挂了→socket.connect_ex(('127.0.0.1',18766))非0即dead→手动`from TMWebDriver import TMWebDriver; TMWebDriver()`起master
## 性能
- ⚠ URL必须用`127.0.0.1`不用`localhost`。Windows下localhost先尝试IPv6(::1)超时2s再回退IPv4每次HTTP请求多2s

250
qqapp.py
View File

@@ -1,8 +1,9 @@
import os, sys, re, threading, asyncio, queue as Q, socket, time, glob import asyncio, os, sys, threading, time
from collections import deque from collections import deque
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
from llmcore import mykeys from llmcore import mykeys
try: try:
@@ -12,73 +13,19 @@ except Exception:
print("Please install qq-botpy to use QQ module: pip install qq-botpy") print("Please install qq-botpy to use QQ module: pip install qq-botpy")
sys.exit(1) sys.exit(1)
agent = GeneraticAgent() agent = GeneraticAgent(); agent.verbose = False
agent.verbose = False
APP_ID = str(mykeys.get("qq_app_id", "") or "").strip() APP_ID = str(mykeys.get("qq_app_id", "") or "").strip()
APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip() APP_SECRET = str(mykeys.get("qq_app_secret", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()} ALLOWED = {str(x).strip() for x in mykeys.get("qq_allowed_users", []) if str(x).strip()}
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")] SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
_PROCESSED_IDS = deque(maxlen=1000)
_USER_TASKS = {}
_SEQ_LOCK = threading.Lock()
_MSG_SEQ = 1
def _next_msg_seq(): def _next_msg_seq():
global _MSG_SEQ global MSG_SEQ
with _SEQ_LOCK: with SEQ_LOCK:
_MSG_SEQ += 1 MSG_SEQ += 1
return _MSG_SEQ return MSG_SEQ
def _clean(text):
for pat in _TAG_PATS:
text = re.sub(pat, "", text, flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
def _extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def _strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def _split_text(text, limit=1500):
text = (text or "").strip() or "..."
parts = []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit * 0.6:
cut = limit
parts.append(text[:cut].rstrip())
text = text[cut:].lstrip()
if text:
parts.append(text)
return parts or ["..."]
def _format_restore():
files = glob.glob("./temp/model_responses_*.txt")
if not files:
return None, "❌ 没有找到历史记录"
latest = max(files, key=os.path.getmtime)
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
count, restored = 0, []
for u, r in zip(users, resps):
u, r = u.strip(), r.strip()[:500]
if u and r:
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
count += 1
if not restored:
return None, "❌ 历史记录里没有可恢复内容"
return (restored, os.path.basename(latest), count), None
def _build_intents(): def _build_intents():
@@ -86,16 +33,7 @@ def _build_intents():
return botpy.Intents(public_messages=True, direct_message=True) return botpy.Intents(public_messages=True, direct_message=True)
except Exception: except Exception:
intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents() intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
for attr in ( for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
"public_messages",
"public_guild_messages",
"direct_message",
"direct_messages",
"c2c_message",
"c2c_messages",
"group_at_message",
"group_at_messages",
):
if hasattr(intents, attr): if hasattr(intents, attr):
try: try:
setattr(intents, attr, True) setattr(intents, attr, True)
@@ -105,15 +43,12 @@ def _build_intents():
def _make_bot_class(app): def _make_bot_class(app):
intents = _build_intents() class QQBot(botpy.Client):
class _QQBot(botpy.Client):
def __init__(self): def __init__(self):
super().__init__(intents=intents, ext_handlers=False) super().__init__(intents=_build_intents(), ext_handlers=False)
async def on_ready(self): async def on_ready(self):
name = getattr(getattr(self, "robot", None), "name", "QQBot") print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
print(f"[QQ] bot ready: {name}")
async def on_c2c_message_create(self, message: C2CMessage): async def on_c2c_message_create(self, message: C2CMessage):
await app.on_message(message, is_group=False) await app.on_message(message, is_group=False)
@@ -124,154 +59,50 @@ def _make_bot_class(app):
async def on_direct_message_create(self, message): async def on_direct_message_create(self, message):
await app.on_message(message, is_group=False) await app.on_message(message, is_group=False)
return _QQBot return QQBot
class QQApp: class QQApp(AgentChatMixin):
label, source, split_limit = "QQ", "qq", 1500
def __init__(self): def __init__(self):
super().__init__(agent, USER_TASKS)
self.client = None self.client = None
async def send_text(self, chat_id, content, *, msg_id=None, is_group=False): async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
if not self.client: if not self.client:
return return
for part in _split_text(content): api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
seq = _next_msg_seq() key = "group_openid" if is_group else "openid"
if is_group: for part in split_text(content, self.split_limit):
await self.client.api.post_group_message( await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": _next_msg_seq()})
group_openid=chat_id,
msg_type=0,
content=part,
msg_id=msg_id,
msg_seq=seq,
)
else:
await self.client.api.post_c2c_message(
openid=chat_id,
msg_type=0,
content=part,
msg_id=msg_id,
msg_seq=seq,
)
async def send_done(self, chat_id, raw_text, *, msg_id=None, is_group=False):
files = [p for p in _extract_files(raw_text) if os.path.exists(p)]
body = _strip_files(_clean(raw_text))
if files:
body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files])
await self.send_text(chat_id, body or "...", msg_id=msg_id, is_group=is_group)
async def handle_command(self, chat_id, cmd, *, msg_id=None, is_group=False):
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/stop":
state = _USER_TASKS.get(chat_id)
if state:
state["running"] = False
agent.abort()
await self.send_text(chat_id, "⏹️ 正在停止...", msg_id=msg_id, is_group=is_group)
elif op == "/status":
llm = agent.get_llm_name() if agent.llmclient else "未配置"
await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}", msg_id=msg_id, is_group=is_group)
elif op == "/llm":
if not agent.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置", msg_id=msg_id, is_group=is_group)
if len(parts) > 1:
try:
n = int(parts[1])
agent.next_llm(n)
await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}", msg_id=msg_id, is_group=is_group)
except Exception:
await self.send_text(chat_id, f"用法: /llm <0-{len(agent.list_llms()) - 1}>", msg_id=msg_id, is_group=is_group)
else:
lines = [f"{'' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()]
await self.send_text(chat_id, "LLMs:\n" + "\n".join(lines), msg_id=msg_id, is_group=is_group)
elif op == "/restore":
try:
restored_info, err = _format_restore()
if err:
return await self.send_text(chat_id, err, msg_id=msg_id, is_group=is_group)
restored, fname, count = restored_info
agent.abort()
agent.history.extend(restored)
await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", msg_id=msg_id, is_group=is_group)
except Exception as e:
await self.send_text(chat_id, f"❌ 恢复失败: {e}", msg_id=msg_id, is_group=is_group)
elif op == "/new":
agent.abort()
agent.history = []
await self.send_text(chat_id, "🆕 已清空当前共享上下文", msg_id=msg_id, is_group=is_group)
else:
await self.send_text(
chat_id,
"📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型",
msg_id=msg_id,
is_group=is_group,
)
async def run_agent(self, chat_id, text, *, msg_id=None, is_group=False):
state = {"running": True}
_USER_TASKS[chat_id] = state
try:
await self.send_text(chat_id, "思考中...", msg_id=msg_id, is_group=is_group)
prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}"
dq = agent.put_task(prompt, source="qq")
last_ping = time.time()
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if agent.is_running and time.time() - last_ping > 20:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...", msg_id=msg_id, is_group=is_group)
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""), msg_id=msg_id, is_group=is_group)
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止", msg_id=msg_id, is_group=is_group)
except Exception as e:
import traceback
print(f"[QQ] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}", msg_id=msg_id, is_group=is_group)
finally:
_USER_TASKS.pop(chat_id, None)
async def on_message(self, data, is_group=False): async def on_message(self, data, is_group=False):
try: try:
msg_id = getattr(data, "id", None) msg_id = getattr(data, "id", None)
if msg_id in _PROCESSED_IDS: if msg_id in PROCESSED_IDS:
return return
_PROCESSED_IDS.append(msg_id) PROCESSED_IDS.append(msg_id)
content = (getattr(data, "content", "") or "").strip() content = (getattr(data, "content", "") or "").strip()
if not content: if not content:
return return
author = getattr(data, "author", None) author = getattr(data, "author", None)
if is_group: user_id = str(getattr(author, "member_openid" if is_group else "user_openid", "") or getattr(author, "id", "") or "unknown")
chat_id = str(getattr(data, "group_openid", "") or "") chat_id = str(getattr(data, "group_openid", "") or user_id) if is_group else user_id
user_id = str(getattr(author, "member_openid", "") or getattr(author, "id", "") or "unknown") if not public_access(ALLOWED) and user_id not in ALLOWED:
else:
user_id = str(getattr(author, "user_openid", "") or getattr(author, "id", "") or "unknown")
chat_id = user_id
public_access = not ALLOWED or "*" in ALLOWED
if not public_access and user_id not in ALLOWED:
print(f"[QQ] unauthorized user: {user_id}") print(f"[QQ] unauthorized user: {user_id}")
return return
print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}") print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
if content.startswith("/"): if content.startswith("/"):
await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group) return await self.handle_command(chat_id, content, msg_id=msg_id, is_group=is_group)
return
asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group)) asyncio.create_task(self.run_agent(chat_id, content, msg_id=msg_id, is_group=is_group))
except Exception: except Exception:
import traceback import traceback
print("[QQ] handle_message error") print("[QQ] handle_message error")
traceback.print_exc() traceback.print_exc()
async def start(self): async def start(self):
BotClass = _make_bot_class(self) self.client = _make_bot_class(self)()
self.client = BotClass()
while True: while True:
try: try:
print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}") print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
@@ -283,25 +114,8 @@ class QQApp:
if __name__ == "__main__": if __name__ == "__main__":
try: _LOCK_SOCK = ensure_single_instance(19528, "QQ")
_lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
_lock_sock.bind(("127.0.0.1", 19528)) redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
except OSError:
print("[QQ] Another instance is already running, skipping...")
sys.exit(1)
if not APP_ID or not APP_SECRET:
print("[QQ] ERROR: please set qq_app_id and qq_app_secret in mykey.py or mykey.json")
sys.exit(1)
if agent.llmclient is None:
print("[QQ] ERROR: no usable LLM backend found in mykey.py or mykey.json")
sys.exit(1)
log_dir = os.path.join(os.path.dirname(__file__), "temp")
os.makedirs(log_dir, exist_ok=True)
_logf = open(os.path.join(log_dir, "qqapp.log"), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = _logf
print("[NEW] QQ process starting, the above are history infos ...")
print(f"[QQ] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}")
threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=agent.run, daemon=True).start()
asyncio.run(QQApp().start()) asyncio.run(QQApp().start())

View File

@@ -17,12 +17,14 @@ function createEnhancedDOMCopy() {
if (sourceNode.nodeType === 3) return sourceNode.cloneNode(false); if (sourceNode.nodeType === 3) return sourceNode.cloneNode(false);
const clone = sourceNode.cloneNode(false); const clone = sourceNode.cloneNode(false);
if ((sourceNode.tagName === 'INPUT' || sourceNode.tagName === 'TEXTAREA') && sourceNode.value) clone.setAttribute('value', sourceNode.value); if ((sourceNode.tagName === 'INPUT' || sourceNode.tagName === 'TEXTAREA') && sourceNode.value) clone.setAttribute('value', sourceNode.value);
if (sourceNode.tagName === 'INPUT' && (sourceNode.type === 'radio' || sourceNode.type === 'checkbox') && sourceNode.checked) clone.setAttribute('checked', '');
else if (sourceNode.tagName === 'SELECT' && sourceNode.value) clone.setAttribute('data-selected', sourceNode.value); else if (sourceNode.tagName === 'SELECT' && sourceNode.value) clone.setAttribute('data-selected', sourceNode.value);
try { if (sourceNode.matches && sourceNode.matches(':-webkit-autofill')) { clone.setAttribute('data-autofilled', 'true'); if (!sourceNode.value) clone.setAttribute('value', '⚠️受保护-读tmwebdriver_sop的autofill章节提取'); } } catch(e) {} try { if (sourceNode.matches && sourceNode.matches(':-webkit-autofill')) { clone.setAttribute('data-autofilled', 'true'); if (!sourceNode.value) clone.setAttribute('value', '⚠️受保护-读tmwebdriver_sop的autofill章节提取'); } } catch(e) {}
const isDropdown = sourceNode.classList?.contains('dropdown-menu') || const isDropdown = sourceNode.classList?.contains('dropdown-menu') ||
/dropdown|menu/i.test(sourceNode.className) || sourceNode.getAttribute('role') === 'menu'; /dropdown|menu/i.test(sourceNode.className) || sourceNode.getAttribute('role') === 'menu';
const isSmallDropdown = isDropdown && (sourceNode.querySelectorAll('a, button, [role="menuitem"], li').length <= 7 && sourceNode.textContent.length < 500); const _ddItems = isDropdown ? sourceNode.querySelectorAll('a, button, [role="menuitem"], li').length : 0;
const isSmallDropdown = _ddItems > 0 && _ddItems <= 7 && sourceNode.textContent.length < 500;
const childNodes = []; const childNodes = [];
for (const child of sourceNode.childNodes) { for (const child of sourceNode.childNodes) {
@@ -704,6 +706,7 @@ js_findMainContent = '''
def optimize_html_for_tokens(html): def optimize_html_for_tokens(html):
if type(html) is str: soup = BeautifulSoup(html, 'html.parser') if type(html) is str: soup = BeautifulSoup(html, 'html.parser')
else: soup = html else: soup = html
for svg in soup.find_all('svg'): svg.clear()
[tag.attrs.pop('style', None) for tag in soup.find_all(True)] [tag.attrs.pop('style', None) for tag in soup.find_all(True)]
for tag in soup.find_all(True): for tag in soup.find_all(True):
if tag.has_attr('src'): if tag.has_attr('src'):
@@ -788,6 +791,10 @@ def find_changed_elements(before_html, after_html):
for sig, els in after_sigs.items(): for sig, els in after_sigs.items():
if sig not in before_sigs: changed.extend(els) if sig not in before_sigs: changed.extend(els)
elif len(els) > len(before_sigs[sig]): changed.extend(els[:len(els) - len(before_sigs[sig])]) elif len(els) > len(before_sigs[sig]): changed.extend(els[:len(els) - len(before_sigs[sig])])
if len(changed) == 0 and str(before_soup) != str(after_soup):
before_els, after_els = before_soup.find_all(True), after_soup.find_all(True)
for i in range(min(len(before_els), len(after_els))):
if get_sig(before_els[i]) != get_sig(after_els[i]): changed.append(after_els[i])
# 变化边界: parent不在changed中的元素 # 变化边界: parent不在changed中的元素
cids = set(id(el) for el in changed) cids = set(id(el) for el in changed)
boundaries = [el for el in changed if el.parent is None or id(el.parent) not in cids] boundaries = [el for el in changed if el.parent is None or id(el.parent) not in cids]

View File

@@ -82,6 +82,11 @@ if "messages" not in st.session_state: st.session_state.messages = []
for msg in st.session_state.messages: for msg in st.session_state.messages:
with st.chat_message(msg["role"]): st.markdown(msg["content"], unsafe_allow_html=True) with st.chat_message(msg["role"]): st.markdown(msg["content"], unsafe_allow_html=True)
# IME composition fix (macOS only) - prevents Enter from submitting during CJK input
if os.name != 'nt':
import streamlit.components.v1 as components
components.html('<script>!function(){if(window.parent.__imeFix)return;window.parent.__imeFix=1;var d=window.parent.document,c=0;d.addEventListener("compositionstart",()=>c=1,!0);d.addEventListener("compositionend",()=>c=0,!0);function f(){d.querySelectorAll("textarea[data-testid=stChatInputTextArea]").forEach(t=>{t.__imeFix||(t.__imeFix=1,t.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.isComposing||c||e.keyCode===229)&&(e.stopImmediatePropagation(),e.preventDefault())},!0))})}f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()</script>', height=0)
if prompt := st.chat_input("请输入指令"): if prompt := st.chat_input("请输入指令"):
st.session_state.messages.append({"role": "user", "content": prompt}) st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"): st.markdown(prompt, unsafe_allow_html=False) # 小心 XSS with st.chat_message("user"): st.markdown(prompt, unsafe_allow_html=False) # 小心 XSS

View File

@@ -1,8 +1,9 @@
import os, sys, re, threading, asyncio, queue as Q, socket, time, glob import asyncio, os, sys, threading
from collections import deque from collections import deque
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
from llmcore import mykeys from llmcore import mykeys
try: try:
@@ -11,207 +12,60 @@ except Exception:
print("Please install wecom_aibot_sdk to use WeCom: pip install wecom_aibot_sdk") print("Please install wecom_aibot_sdk to use WeCom: pip install wecom_aibot_sdk")
sys.exit(1) sys.exit(1)
agent = GeneraticAgent() agent = GeneraticAgent(); agent.verbose = False
agent.verbose = False
BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip() BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip()
SECRET = str(mykeys.get("wecom_secret", "") or "").strip() SECRET = str(mykeys.get("wecom_secret", "") or "").strip()
WELCOME = str(mykeys.get("wecom_welcome_message", "") or "").strip() WELCOME = str(mykeys.get("wecom_welcome_message", "") or "").strip()
ALLOWED = {str(x).strip() for x in mykeys.get("wecom_allowed_users", []) if str(x).strip()} ALLOWED = {str(x).strip() for x in mykeys.get("wecom_allowed_users", []) if str(x).strip()}
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
_PROCESSED_IDS = deque(maxlen=1000)
_USER_TASKS = {}
def _clean(text): class WeComApp(AgentChatMixin):
for pat in _TAG_PATS: label, source, split_limit = "WeCom", "wecom", 1200
text = re.sub(pat, "", text, flags=re.DOTALL)
return re.sub(r"\n{3,}", "\n\n", text).strip() or "..."
def _extract_files(text):
return re.findall(r"\[FILE:([^\]]+)\]", text or "")
def _strip_files(text):
return re.sub(r"\[FILE:[^\]]+\]", "", text or "").strip()
def _split_text(text, limit=1200):
text = (text or "").strip() or "..."
parts = []
while len(text) > limit:
cut = text.rfind("\n", 0, limit)
if cut < limit * 0.6:
cut = limit
parts.append(text[:cut].rstrip())
text = text[cut:].lstrip()
if text:
parts.append(text)
return parts or ["..."]
def _format_restore():
files = glob.glob("./temp/model_responses_*.txt")
if not files:
return None, "❌ 没有找到历史记录"
latest = max(files, key=os.path.getmtime)
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
users = re.findall(r"=== USER ===\n(.+?)(?==== |$)", content, re.DOTALL)
resps = re.findall(r"=== Response ===.*?\n(.+?)(?==== Prompt|$)", content, re.DOTALL)
count, restored = 0, []
for u, r in zip(users, resps):
u, r = u.strip(), r.strip()[:500]
if u and r:
restored.extend([f"[USER]: {u}", f"[Agent] {r}"])
count += 1
if not restored:
return None, "❌ 历史记录里没有可恢复内容"
return (restored, os.path.basename(latest), count), None
class WeComApp:
def __init__(self): def __init__(self):
self.client = None super().__init__(agent, USER_TASKS)
self.chat_frames = {} self.client, self.chat_frames = None, {}
def _body(self, frame):
if hasattr(frame, "body"):
return frame.body or {}
if isinstance(frame, dict):
return frame.get("body", frame)
return {}
async def send_text(self, chat_id, content): async def send_text(self, chat_id, content):
if not self.client: if not self.client or chat_id not in self.chat_frames:
return if chat_id not in self.chat_frames:
frame = self.chat_frames.get(chat_id)
if not frame:
print(f"[WeCom] no frame found for chat: {chat_id}") print(f"[WeCom] no frame found for chat: {chat_id}")
return return
for part in _split_text(content): frame = self.chat_frames[chat_id]
stream_id = generate_req_id("stream") for part in split_text(content, self.split_limit):
await self.client.reply_stream(frame, stream_id, part, finish=True) await self.client.reply_stream(frame, generate_req_id("stream"), part, finish=True)
async def send_done(self, chat_id, raw_text):
files = [p for p in _extract_files(raw_text) if os.path.exists(p)]
body = _strip_files(_clean(raw_text))
if files:
body = (body + "\n\n" if body else "") + "\n".join([f"生成文件: {p}" for p in files])
await self.send_text(chat_id, body or "...")
async def handle_command(self, chat_id, cmd):
parts = (cmd or "").split()
op = (parts[0] if parts else "").lower()
if op == "/stop":
state = _USER_TASKS.get(chat_id)
if state:
state["running"] = False
agent.abort()
await self.send_text(chat_id, "⏹️ 正在停止...")
elif op == "/status":
llm = agent.get_llm_name() if agent.llmclient else "未配置"
await self.send_text(chat_id, f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}")
elif op == "/llm":
if not agent.llmclient:
return await self.send_text(chat_id, "❌ 当前没有可用的 LLM 配置")
if len(parts) > 1:
try:
n = int(parts[1])
agent.next_llm(n)
await self.send_text(chat_id, f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}")
except Exception:
await self.send_text(chat_id, 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 self.send_text(chat_id, "LLMs:\n" + "\n".join(lines))
elif op == "/restore":
try:
restored_info, err = _format_restore()
if err:
return await self.send_text(chat_id, err)
restored, fname, count = restored_info
agent.abort()
agent.history.extend(restored)
await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
except Exception as e:
await self.send_text(chat_id, f"❌ 恢复失败: {e}")
elif op == "/new":
agent.abort()
agent.history = []
await self.send_text(chat_id, "🆕 已清空当前共享上下文")
else:
await self.send_text(
chat_id,
"📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型",
)
async def run_agent(self, chat_id, text):
state = {"running": True}
_USER_TASKS[chat_id] = state
try:
await self.send_text(chat_id, "思考中...")
prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{text}"
dq = agent.put_task(prompt, source="wecom")
last_ping = time.time()
while state["running"]:
try:
item = await asyncio.to_thread(dq.get, True, 3)
except Q.Empty:
if agent.is_running and time.time() - last_ping > 20:
await self.send_text(chat_id, "⏳ 还在处理中,请稍等...")
last_ping = time.time()
continue
if "done" in item:
await self.send_done(chat_id, item.get("done", ""))
break
if not state["running"]:
await self.send_text(chat_id, "⏹️ 已停止")
except Exception as e:
import traceback
print(f"[WeCom] run_agent error: {e}")
traceback.print_exc()
await self.send_text(chat_id, f"❌ 错误: {e}")
finally:
_USER_TASKS.pop(chat_id, None)
async def on_text(self, frame): async def on_text(self, frame):
try: try:
body = self._body(frame) body = frame.body if hasattr(frame, "body") else frame.get("body", frame) if isinstance(frame, dict) else {}
if not isinstance(body, dict): if not isinstance(body, dict):
return return
msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}" msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
if msg_id in _PROCESSED_IDS: if msg_id in PROCESSED_IDS:
return return
_PROCESSED_IDS.append(msg_id) PROCESSED_IDS.append(msg_id)
from_info = body.get("from", {}) if isinstance(body.get("from", {}), dict) else {} from_info = body.get("from", {}) if isinstance(body.get("from", {}), dict) else {}
sender_id = str(from_info.get("userid", "") or "unknown") sender_id = str(from_info.get("userid", "") or "unknown")
chat_id = str(body.get("chatid", "") or sender_id) chat_id = str(body.get("chatid", "") or sender_id)
content = str((body.get("text", {}) or {}).get("content", "") or "").strip() content = str((body.get("text", {}) or {}).get("content", "") or "").strip()
if not content: if not content:
return return
public_access = not ALLOWED or "*" in ALLOWED if not public_access(ALLOWED) and sender_id not in ALLOWED:
if not public_access and sender_id not in ALLOWED:
print(f"[WeCom] unauthorized user: {sender_id}") print(f"[WeCom] unauthorized user: {sender_id}")
return return
self.chat_frames[chat_id] = frame self.chat_frames[chat_id] = frame
print(f"[WeCom] message from {sender_id}: {content}") print(f"[WeCom] message from {sender_id}: {content}")
if content.startswith("/"): if content.startswith("/"):
await self.handle_command(chat_id, content) return await self.handle_command(chat_id, content)
return
asyncio.create_task(self.run_agent(chat_id, content)) asyncio.create_task(self.run_agent(chat_id, content))
except Exception: except Exception:
import traceback import traceback
print("[WeCom] handle_message error") print("[WeCom] handle_message error")
traceback.print_exc() traceback.print_exc()
async def on_enter_chat(self, frame): async def on_enter_chat(self, frame):
if not WELCOME or not self.client: if WELCOME and self.client:
return
try: try:
await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}}) await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}})
except Exception as e: except Exception as e:
@@ -230,19 +84,16 @@ class WeComApp:
print(f"[WeCom] error: {frame}") print(f"[WeCom] error: {frame}")
async def start(self): async def start(self):
self.client = WSClient({ self.client = WSClient({"bot_id": BOT_ID, "secret": SECRET, "reconnect_interval": 1000, "max_reconnect_attempts": -1, "heartbeat_interval": 30000})
"bot_id": BOT_ID, for event, handler in {
"secret": SECRET, "connected": self.on_connected,
"reconnect_interval": 1000, "authenticated": self.on_authenticated,
"max_reconnect_attempts": -1, "disconnected": self.on_disconnected,
"heartbeat_interval": 30000, "error": self.on_error,
}) "message.text": self.on_text,
self.client.on("connected", self.on_connected) "event.enter_chat": self.on_enter_chat,
self.client.on("authenticated", self.on_authenticated) }.items():
self.client.on("disconnected", self.on_disconnected) self.client.on(event, handler)
self.client.on("error", self.on_error)
self.client.on("message.text", self.on_text)
self.client.on("event.enter_chat", self.on_enter_chat)
print("[WeCom] bot starting...") print("[WeCom] bot starting...")
await self.client.connect_async() await self.client.connect_async()
while True: while True:
@@ -250,25 +101,8 @@ class WeComApp:
if __name__ == "__main__": if __name__ == "__main__":
try: _LOCK_SOCK = ensure_single_instance(19529, "WeCom")
_lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) require_runtime(agent, "WeCom", wecom_bot_id=BOT_ID, wecom_secret=SECRET)
_lock_sock.bind(("127.0.0.1", 19529)) redirect_log(__file__, "wecomapp.log", "WeCom", ALLOWED)
except OSError:
print("[WeCom] Another instance is already running, skipping...")
sys.exit(1)
if not BOT_ID or not SECRET:
print("[WeCom] ERROR: please set wecom_bot_id and wecom_secret in mykey.py or mykey.json")
sys.exit(1)
if agent.llmclient is None:
print("[WeCom] ERROR: no usable LLM backend found in mykey.py or mykey.json")
sys.exit(1)
log_dir = os.path.join(os.path.dirname(__file__), "temp")
os.makedirs(log_dir, exist_ok=True)
_logf = open(os.path.join(log_dir, "wecomapp.log"), "a", encoding="utf-8", buffering=1)
sys.stdout = sys.stderr = _logf
print("[NEW] WeCom process starting, the above are history infos ...")
print(f"[WeCom] allow list: {'public' if not ALLOWED or '*' in ALLOWED else sorted(ALLOWED)}")
threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=agent.run, daemon=True).start()
asyncio.run(WeComApp().start()) asyncio.run(WeComApp().start())