Merge upstream/main with local fsapp.py preserved
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
21
LICENSE
Normal 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.
|
||||||
@@ -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️⃣ **能力扩展机制**
|
||||||
> 具备动态创建新的工具能力
|
> 具备动态创建新的工具能力
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)
|
||||||
42
agentmain.py
42
agentmain.py
@@ -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}')
|
||||||
|
|||||||
@@ -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进入规划模式
|
||||||
|
|||||||
@@ -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
186
chatapp_common.py
Normal 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)
|
||||||
225
dingtalkapp.py
225
dingtalkapp.py
@@ -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
18
ga.py
@@ -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 保存关键上下文。"
|
||||||
|
|||||||
108
llmcore.py
108
llmcore.py
@@ -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):
|
||||||
if self._should_use_structured_messages(messages):
|
|
||||||
return (yield from self._chat_structured(messages, tools))
|
|
||||||
full_prompt = self._build_protocol_prompt(messages, tools)
|
|
||||||
print("Full prompt length:", len(full_prompt), 'chars')
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
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:
|
log_path = os.path.join(script_dir, f'./temp/model_responses_{os.getpid()}.txt')
|
||||||
f.write(f"=== Prompt === {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n{full_prompt}\n")
|
if self._should_use_structured_messages(messages):
|
||||||
gen = self.backend.ask(full_prompt, stream=True)
|
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)
|
||||||
|
print("Full prompt length:", len(full_prompt), 'chars')
|
||||||
|
prompt_log = full_prompt
|
||||||
|
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))
|
||||||
|
|||||||
@@ -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 + 了解历史避免重复
|
||||||
|
|
||||||
**执行**:
|
**执行**:
|
||||||
|
|||||||
64
memory/skill_search/SKILL.md
Normal file
64
memory/skill_search/SKILL.md
Normal 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` |
|
||||||
8
memory/skill_search/skill_search/__init__.py
Normal file
8
memory/skill_search/skill_search/__init__.py
Normal 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"]
|
||||||
116
memory/skill_search/skill_search/__main__.py
Normal file
116
memory/skill_search/skill_search/__main__.py
Normal 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 检索系统 — 根据环境和需求智能推荐 skill(API 客户端)")
|
||||||
|
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()
|
||||||
156
memory/skill_search/skill_search/engine.py
Normal file
156
memory/skill_search/skill_search/engine.py
Normal 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})
|
||||||
@@ -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
250
qqapp.py
@@ -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())
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
5
stapp.py
5
stapp.py
@@ -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
|
||||||
|
|||||||
240
wecomapp.py
240
wecomapp.py
@@ -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,211 +12,64 @@ 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:
|
||||||
|
if chat_id not in self.chat_frames:
|
||||||
|
print(f"[WeCom] no frame found for chat: {chat_id}")
|
||||||
return
|
return
|
||||||
frame = self.chat_frames.get(chat_id)
|
frame = self.chat_frames[chat_id]
|
||||||
if not frame:
|
for part in split_text(content, self.split_limit):
|
||||||
print(f"[WeCom] no frame found for chat: {chat_id}")
|
await self.client.reply_stream(frame, generate_req_id("stream"), part, finish=True)
|
||||||
return
|
|
||||||
for part in _split_text(content):
|
|
||||||
stream_id = generate_req_id("stream")
|
|
||||||
await self.client.reply_stream(frame, stream_id, 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:
|
print(f"[WeCom] welcome error: {e}")
|
||||||
print(f"[WeCom] welcome error: {e}")
|
|
||||||
|
|
||||||
async def on_connected(self, frame):
|
async def on_connected(self, frame):
|
||||||
print("[WeCom] connected")
|
print("[WeCom] connected")
|
||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user