refactor: move frontend apps to frontends/ and fix path references
This commit is contained in:
186
frontends/chatapp_common.py
Normal file
186
frontends/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(os.path.dirname(os.path.abspath(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)
|
||||
140
frontends/dingtalkapp.py
Normal file
140
frontends/dingtalkapp.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import asyncio, json, os, sys, threading, time
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
from dingtalk_stream import AckMessage, CallbackHandler, Credential, DingTalkStreamClient
|
||||
from dingtalk_stream.chatbot import ChatbotMessage
|
||||
except Exception:
|
||||
print("Please install dingtalk-stream to use DingTalk: pip install dingtalk-stream")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
CLIENT_ID = str(mykeys.get("dingtalk_client_id", "") 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()}
|
||||
USER_TASKS = {}
|
||||
|
||||
|
||||
class DingTalkApp(AgentChatMixin):
|
||||
label, source, split_limit = "DingTalk", "dingtalk", 1800
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
self.client, self.access_token, self.token_expiry, self.background_tasks = None, None, 0, set()
|
||||
|
||||
async def _get_access_token(self):
|
||||
if self.access_token and time.time() < self.token_expiry:
|
||||
return self.access_token
|
||||
|
||||
def _fetch():
|
||||
resp = requests.post("https://api.dingtalk.com/v1.0/oauth2/accessToken", json={"appKey": CLIENT_ID, "appSecret": CLIENT_SECRET}, timeout=20)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(_fetch)
|
||||
self.access_token = data.get("accessToken")
|
||||
self.token_expiry = time.time() + int(data.get("expireIn", 7200)) - 60
|
||||
return self.access_token
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] token error: {e}")
|
||||
return None
|
||||
|
||||
async def _send_batch_message(self, chat_id, msg_key, msg_param):
|
||||
token = await self._get_access_token()
|
||||
if not token:
|
||||
return False
|
||||
headers = {"x-acs-dingtalk-access-token": token}
|
||||
if chat_id.startswith("group:"):
|
||||
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||
payload = {"robotCode": CLIENT_ID, "openConversationId": chat_id[6:], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
|
||||
else:
|
||||
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
|
||||
payload = {"robotCode": CLIENT_ID, "userIds": [chat_id], "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False)}
|
||||
|
||||
def _post():
|
||||
resp = requests.post(url, json=payload, headers=headers, timeout=20)
|
||||
body = resp.text
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {resp.status_code}: {body[:300]}")
|
||||
result = resp.json() if "json" in resp.headers.get("content-type", "") else {}
|
||||
errcode = result.get("errcode")
|
||||
if errcode not in (None, 0):
|
||||
raise RuntimeError(f"API errcode={errcode}: {body[:300]}")
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_post)
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] send error: {e}")
|
||||
return False
|
||||
|
||||
async def send_text(self, chat_id, content):
|
||||
for part in split_text(content, self.split_limit):
|
||||
await self._send_batch_message(chat_id, "sampleMarkdown", {"text": part, "title": "Agent Reply"})
|
||||
|
||||
async def on_message(self, content, sender_id, sender_name, conversation_type=None, conversation_id=None):
|
||||
try:
|
||||
if not content:
|
||||
return
|
||||
if not public_access(ALLOWED) and sender_id not in ALLOWED:
|
||||
print(f"[DingTalk] unauthorized user: {sender_id}")
|
||||
return
|
||||
is_group = conversation_type == "2" and conversation_id
|
||||
chat_id = f"group:{conversation_id}" if is_group else sender_id
|
||||
print(f"[DingTalk] message from {sender_name} ({sender_id}): {content}")
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content)
|
||||
task = asyncio.create_task(self.run_agent(chat_id, content))
|
||||
self.background_tasks.add(task)
|
||||
task.add_done_callback(self.background_tasks.discard)
|
||||
except Exception:
|
||||
import traceback
|
||||
print("[DingTalk] handle_message error")
|
||||
traceback.print_exc()
|
||||
|
||||
async def start(self):
|
||||
self.client = DingTalkStreamClient(Credential(CLIENT_ID, CLIENT_SECRET))
|
||||
self.client.register_callback_handler(ChatbotMessage.TOPIC, _DingTalkHandler(self))
|
||||
print("[DingTalk] bot starting...")
|
||||
while True:
|
||||
try:
|
||||
await self.client.start()
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] stream error: {e}")
|
||||
print("[DingTalk] reconnect in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
class _DingTalkHandler(CallbackHandler):
|
||||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.app = app
|
||||
|
||||
async def process(self, message):
|
||||
try:
|
||||
chatbot_msg = ChatbotMessage.from_dict(message.data)
|
||||
text = getattr(getattr(chatbot_msg, "text", None), "content", "") or ""
|
||||
extensions = getattr(chatbot_msg, "extensions", None) or {}
|
||||
recognition = ((extensions.get("content") or {}).get("recognition") or "").strip() if isinstance(extensions, dict) else ""
|
||||
if not (text := text.strip()):
|
||||
text = recognition or str((message.data.get("text", {}) or {}).get("content", "") or "").strip()
|
||||
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"
|
||||
await self.app.on_message(text, sender_id, sender_name, message.data.get("conversationType"), message.data.get("conversationId") or message.data.get("openConversationId"))
|
||||
except Exception as e:
|
||||
print(f"[DingTalk] callback error: {e}")
|
||||
return AckMessage.STATUS_OK, "OK"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19530, "DingTalk")
|
||||
require_runtime(agent, "DingTalk", dingtalk_client_id=CLIENT_ID, dingtalk_client_secret=CLIENT_SECRET)
|
||||
redirect_log(__file__, "dingtalkapp.log", "DingTalk", ALLOWED)
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
asyncio.run(DingTalkApp().start())
|
||||
554
frontends/fsapp.py
Normal file
554
frontends/fsapp.py
Normal file
@@ -0,0 +1,554 @@
|
||||
import glob, json, os, queue as Q, re, sys, threading, time
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
os.chdir(PROJECT_ROOT)
|
||||
from agentmain import GeneraticAgent
|
||||
from llmcore import mykeys
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import *
|
||||
|
||||
_TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||
_AUDIO_EXTS = {".opus", ".mp3", ".wav", ".m4a", ".aac"}
|
||||
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
|
||||
_FILE_TYPE_MAP = {
|
||||
".opus": "opus",
|
||||
".mp4": "mp4",
|
||||
".pdf": "pdf",
|
||||
".doc": "doc",
|
||||
".docx": "doc",
|
||||
".xls": "xls",
|
||||
".xlsx": "xls",
|
||||
".ppt": "ppt",
|
||||
".pptx": "ppt",
|
||||
}
|
||||
_MSG_TYPE_MAP = {"image": "[image]", "audio": "[audio]", "file": "[file]", "media": "[media]", "sticker": "[sticker]"}
|
||||
|
||||
TEMP_DIR = os.path.join(PROJECT_ROOT, "temp")
|
||||
MEDIA_DIR = os.path.join(TEMP_DIR, "feishu_media")
|
||||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
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 _display_text(text):
|
||||
return _strip_files(_clean(text)) or "..."
|
||||
|
||||
|
||||
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 _parse_json(raw):
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_share_card_content(content_json, msg_type):
|
||||
parts = []
|
||||
if msg_type == "share_chat":
|
||||
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
|
||||
elif msg_type == "share_user":
|
||||
parts.append(f"[shared user: {content_json.get('user_id', '')}]")
|
||||
elif msg_type == "interactive":
|
||||
parts.extend(_extract_interactive_content(content_json))
|
||||
elif msg_type == "share_calendar_event":
|
||||
parts.append(f"[shared calendar event: {content_json.get('event_key', '')}]")
|
||||
elif msg_type == "system":
|
||||
parts.append("[system message]")
|
||||
elif msg_type == "merge_forward":
|
||||
parts.append("[merged forward messages]")
|
||||
return "\n".join([p for p in parts if p]).strip() or f"[{msg_type}]"
|
||||
|
||||
|
||||
def _extract_interactive_content(content):
|
||||
parts = []
|
||||
if isinstance(content, str):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except Exception:
|
||||
return [content] if content.strip() else []
|
||||
if not isinstance(content, dict):
|
||||
return parts
|
||||
title = content.get("title")
|
||||
if isinstance(title, dict):
|
||||
title_text = title.get("content", "") or title.get("text", "")
|
||||
if title_text:
|
||||
parts.append(f"title: {title_text}")
|
||||
elif isinstance(title, str) and title:
|
||||
parts.append(f"title: {title}")
|
||||
elements = content.get("elements", [])
|
||||
if isinstance(elements, list):
|
||||
for row in elements:
|
||||
if isinstance(row, dict):
|
||||
parts.extend(_extract_element_content(row))
|
||||
elif isinstance(row, list):
|
||||
for el in row:
|
||||
parts.extend(_extract_element_content(el))
|
||||
card = content.get("card", {})
|
||||
if card:
|
||||
parts.extend(_extract_interactive_content(card))
|
||||
header = content.get("header", {})
|
||||
if isinstance(header, dict):
|
||||
header_title = header.get("title", {})
|
||||
if isinstance(header_title, dict):
|
||||
header_text = header_title.get("content", "") or header_title.get("text", "")
|
||||
if header_text:
|
||||
parts.append(f"title: {header_text}")
|
||||
return [p for p in parts if p]
|
||||
|
||||
|
||||
def _extract_element_content(element):
|
||||
parts = []
|
||||
if not isinstance(element, dict):
|
||||
return parts
|
||||
tag = element.get("tag", "")
|
||||
if tag in ("markdown", "lark_md"):
|
||||
content = element.get("content", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
elif tag == "div":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
text_content = text.get("content", "") or text.get("text", "")
|
||||
if text_content:
|
||||
parts.append(text_content)
|
||||
elif isinstance(text, str) and text:
|
||||
parts.append(text)
|
||||
for field in element.get("fields", []) or []:
|
||||
if isinstance(field, dict):
|
||||
field_text = field.get("text", {})
|
||||
if isinstance(field_text, dict):
|
||||
content = field_text.get("content", "") or field_text.get("text", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
elif tag == "a":
|
||||
href = element.get("href", "")
|
||||
text = element.get("text", "")
|
||||
if href:
|
||||
parts.append(f"link: {href}")
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif tag == "button":
|
||||
text = element.get("text", {})
|
||||
if isinstance(text, dict):
|
||||
content = text.get("content", "") or text.get("text", "")
|
||||
if content:
|
||||
parts.append(content)
|
||||
url = element.get("url", "") or (element.get("multi_url", {}) or {}).get("url", "")
|
||||
if url:
|
||||
parts.append(f"link: {url}")
|
||||
elif tag == "img":
|
||||
alt = element.get("alt", {})
|
||||
if isinstance(alt, dict):
|
||||
parts.append(alt.get("content", "[image]") or "[image]")
|
||||
else:
|
||||
parts.append("[image]")
|
||||
for child in element.get("elements", []) or []:
|
||||
parts.extend(_extract_element_content(child))
|
||||
for col in element.get("columns", []) or []:
|
||||
for child in (col.get("elements", []) if isinstance(col, dict) else []):
|
||||
parts.extend(_extract_element_content(child))
|
||||
return parts
|
||||
|
||||
|
||||
def _extract_post_content(content_json):
|
||||
def _parse_block(block):
|
||||
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
|
||||
return None, []
|
||||
texts, images = [], []
|
||||
if block.get("title"):
|
||||
texts.append(block.get("title"))
|
||||
for row in block["content"]:
|
||||
if not isinstance(row, list):
|
||||
continue
|
||||
for el in row:
|
||||
if not isinstance(el, dict):
|
||||
continue
|
||||
tag = el.get("tag")
|
||||
if tag in ("text", "a"):
|
||||
texts.append(el.get("text", ""))
|
||||
elif tag == "at":
|
||||
texts.append(f"@{el.get('user_name', 'user')}")
|
||||
elif tag == "img" and el.get("image_key"):
|
||||
images.append(el["image_key"])
|
||||
text = " ".join([t for t in texts if t]).strip()
|
||||
return text or None, images
|
||||
|
||||
root = content_json
|
||||
if isinstance(root, dict) and isinstance(root.get("post"), dict):
|
||||
root = root["post"]
|
||||
if not isinstance(root, dict):
|
||||
return "", []
|
||||
if "content" in root:
|
||||
text, imgs = _parse_block(root)
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
for key in ("zh_cn", "en_us", "ja_jp"):
|
||||
if key in root:
|
||||
text, imgs = _parse_block(root[key])
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
for val in root.values():
|
||||
if isinstance(val, dict):
|
||||
text, imgs = _parse_block(val)
|
||||
if text or imgs:
|
||||
return text or "", imgs
|
||||
return "", []
|
||||
|
||||
|
||||
APP_ID = str(mykeys.get("fs_app_id", "") or "").strip()
|
||||
APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip()
|
||||
ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", []))
|
||||
PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS
|
||||
|
||||
agent = GeneraticAgent()
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
client, user_tasks = None, {}
|
||||
|
||||
|
||||
def create_client():
|
||||
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
|
||||
|
||||
|
||||
def _card(text):
|
||||
return json.dumps({"config": {"wide_screen_mode": True}, "elements": [{"tag": "markdown", "content": text}]}, ensure_ascii=False)
|
||||
|
||||
|
||||
def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
|
||||
if use_card:
|
||||
payload, real_type = _card(content), "interactive"
|
||||
elif msg_type == "text":
|
||||
payload, real_type = json.dumps({"text": content}, ensure_ascii=False), "text"
|
||||
else:
|
||||
payload, real_type = content, msg_type
|
||||
body = CreateMessageRequest.builder().receive_id_type(receive_id_type).request_body(
|
||||
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(real_type).content(payload).build()
|
||||
).build()
|
||||
response = client.im.v1.message.create(body)
|
||||
if response.success():
|
||||
return response.data.message_id if response.data else None
|
||||
print(f"发送失败: {response.code}, {response.msg}")
|
||||
return None
|
||||
|
||||
|
||||
def update_message(message_id, content):
|
||||
body = PatchMessageRequest.builder().message_id(message_id).request_body(
|
||||
PatchMessageRequestBody.builder().content(_card(content)).build()
|
||||
).build()
|
||||
response = client.im.v1.message.patch(body)
|
||||
if not response.success():
|
||||
print(f"[ERROR] update_message 失败: {response.code}, {response.msg}")
|
||||
return response.success()
|
||||
|
||||
|
||||
def _upload_image_sync(file_path):
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateImageRequest.builder().request_body(
|
||||
CreateImageRequestBody.builder().image_type("message").image(f).build()
|
||||
).build()
|
||||
response = client.im.v1.image.create(request)
|
||||
if response.success():
|
||||
return response.data.image_key
|
||||
print(f"[ERROR] upload image failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] upload image failed {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _upload_file_sync(file_path):
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
file_type = _FILE_TYPE_MAP.get(ext, "stream")
|
||||
file_name = os.path.basename(file_path)
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
request = CreateFileRequest.builder().request_body(
|
||||
CreateFileRequestBody.builder().file_type(file_type).file_name(file_name).file(f).build()
|
||||
).build()
|
||||
response = client.im.v1.file.create(request)
|
||||
if response.success():
|
||||
return response.data.file_key
|
||||
print(f"[ERROR] upload file failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] upload file failed {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _download_image_sync(message_id, image_key):
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(image_key).type("image").build()
|
||||
response = client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
data = response.file.read() if hasattr(response.file, "read") else response.file
|
||||
return data, response.file_name
|
||||
print(f"[ERROR] download image failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] download image failed {image_key}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_file_sync(message_id, file_key, resource_type="file"):
|
||||
if resource_type == "audio":
|
||||
resource_type = "file"
|
||||
try:
|
||||
request = GetMessageResourceRequest.builder().message_id(message_id).file_key(file_key).type(resource_type).build()
|
||||
response = client.im.v1.message_resource.get(request)
|
||||
if response.success():
|
||||
data = response.file.read() if hasattr(response.file, "read") else response.file
|
||||
return data, response.file_name
|
||||
print(f"[ERROR] download {resource_type} failed: {response.code}, {response.msg}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] download {resource_type} failed {file_key}: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_and_save_media(msg_type, content_json, message_id):
|
||||
data, filename = None, None
|
||||
if msg_type == "image":
|
||||
image_key = content_json.get("image_key")
|
||||
if image_key and message_id:
|
||||
data, filename = _download_image_sync(message_id, image_key)
|
||||
if not filename:
|
||||
filename = f"{image_key[:16]}.jpg"
|
||||
elif msg_type in ("audio", "file", "media"):
|
||||
file_key = content_json.get("file_key")
|
||||
if file_key and message_id:
|
||||
data, filename = _download_file_sync(message_id, file_key, msg_type)
|
||||
if not filename:
|
||||
filename = file_key[:16]
|
||||
if msg_type == "audio" and filename and not filename.endswith(".opus"):
|
||||
filename = f"{filename}.opus"
|
||||
if data and filename:
|
||||
file_path = os.path.join(MEDIA_DIR, os.path.basename(filename))
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(data)
|
||||
return file_path, filename
|
||||
return None, None
|
||||
|
||||
|
||||
def _describe_media(msg_type, file_path, filename):
|
||||
if msg_type == "image":
|
||||
return f"[image: {filename}]\n[Image: source: {file_path}]"
|
||||
if msg_type == "audio":
|
||||
return f"[audio: {filename}]\n[File: source: {file_path}]"
|
||||
if msg_type in ("file", "media"):
|
||||
return f"[{msg_type}: {filename}]\n[File: source: {file_path}]"
|
||||
return f"[{msg_type}]\n[File: source: {file_path}]"
|
||||
|
||||
|
||||
def _send_local_file(receive_id, file_path, receive_id_type="open_id"):
|
||||
if not os.path.isfile(file_path):
|
||||
send_message(receive_id, f"⚠️ 文件不存在: {file_path}", receive_id_type=receive_id_type)
|
||||
return False
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in _IMAGE_EXTS:
|
||||
image_key = _upload_image_sync(file_path)
|
||||
if image_key:
|
||||
send_message(receive_id, json.dumps({"image_key": image_key}, ensure_ascii=False), msg_type="image", receive_id_type=receive_id_type)
|
||||
return True
|
||||
else:
|
||||
file_key = _upload_file_sync(file_path)
|
||||
if file_key:
|
||||
msg_type = "media" if ext in _AUDIO_EXTS or ext in _VIDEO_EXTS else "file"
|
||||
send_message(receive_id, json.dumps({"file_key": file_key}, ensure_ascii=False), msg_type=msg_type, receive_id_type=receive_id_type)
|
||||
return True
|
||||
send_message(receive_id, f"⚠️ 文件发送失败: {os.path.basename(file_path)}", receive_id_type=receive_id_type)
|
||||
return False
|
||||
|
||||
|
||||
def _send_generated_files(receive_id, raw_text, receive_id_type="open_id"):
|
||||
for file_path in _extract_files(raw_text):
|
||||
_send_local_file(receive_id, file_path, receive_id_type)
|
||||
|
||||
|
||||
def _build_user_message(message):
|
||||
msg_type = message.message_type
|
||||
message_id = message.message_id
|
||||
content_json = _parse_json(message.content)
|
||||
parts, image_paths = [], []
|
||||
if msg_type == "text":
|
||||
text = str(content_json.get("text", "") or "").strip()
|
||||
if text:
|
||||
parts.append(text)
|
||||
elif msg_type == "post":
|
||||
text, image_keys = _extract_post_content(content_json)
|
||||
if text:
|
||||
parts.append(text)
|
||||
for image_key in image_keys:
|
||||
file_path, filename = _download_and_save_media("image", {"image_key": image_key}, message_id)
|
||||
if file_path and filename:
|
||||
parts.append(_describe_media("image", file_path, filename))
|
||||
image_paths.append(file_path)
|
||||
else:
|
||||
parts.append("[image: download failed]")
|
||||
elif msg_type in ("image", "audio", "file", "media"):
|
||||
file_path, filename = _download_and_save_media(msg_type, content_json, message_id)
|
||||
if file_path and filename:
|
||||
parts.append(_describe_media(msg_type, file_path, filename))
|
||||
if msg_type == "image":
|
||||
image_paths.append(file_path)
|
||||
else:
|
||||
parts.append(f"[{msg_type}: download failed]")
|
||||
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
|
||||
parts.append(_extract_share_card_content(content_json, msg_type))
|
||||
else:
|
||||
parts.append(_MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
|
||||
return "\n".join([p for p in parts if p]).strip(), image_paths
|
||||
|
||||
|
||||
def handle_message(data):
|
||||
event, message, sender = data.event, data.event.message, data.event.sender
|
||||
open_id = sender.sender_id.open_id
|
||||
chat_id = message.chat_id
|
||||
if not PUBLIC_ACCESS and open_id not in ALLOWED_USERS:
|
||||
print(f"未授权用户: {open_id}")
|
||||
return
|
||||
user_input, image_paths = _build_user_message(message)
|
||||
if not user_input:
|
||||
if chat_id:
|
||||
send_message(chat_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}", receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, f"⚠️ 暂不支持处理此类飞书消息:{message.message_type}")
|
||||
return
|
||||
print(f"收到消息 [{open_id}] ({message.message_type}, {len(image_paths)} images): {user_input[:200]}")
|
||||
if message.message_type == "text" and user_input.startswith("/"):
|
||||
return handle_command(open_id, user_input, chat_id)
|
||||
|
||||
def run_agent():
|
||||
user_tasks[open_id] = {"running": True}
|
||||
try:
|
||||
if chat_id:
|
||||
msg_id, dq, last_text = send_message(chat_id, "思考中...", use_card=True, receive_id_type="chat_id"), agent.put_task(user_input, source="feishu", images=image_paths), ""
|
||||
else:
|
||||
msg_id, dq, last_text = send_message(open_id, "思考中...", use_card=True), agent.put_task(user_input, source="feishu", images=image_paths), ""
|
||||
while user_tasks.get(open_id, {}).get("running", False):
|
||||
time.sleep(3)
|
||||
item = None
|
||||
try:
|
||||
while True:
|
||||
item = dq.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
if item is None:
|
||||
continue
|
||||
raw = item.get("done") or item.get("next", "")
|
||||
done = "done" in item
|
||||
show = _display_text(raw)
|
||||
if len(show) > 3500:
|
||||
cut = show[-3000:]
|
||||
if cut.count("```") % 2 == 1:
|
||||
cut = "```\n" + cut
|
||||
if chat_id:
|
||||
msg_id, last_text, show = send_message(chat_id, "(继续...)", use_card=True, receive_id_type="chat_id"), "", cut
|
||||
else:
|
||||
msg_id, last_text, show = send_message(open_id, "(继续...)", use_card=True), "", cut
|
||||
display = show if done else show + " ⏳"
|
||||
if display != last_text and msg_id:
|
||||
update_message(msg_id, display)
|
||||
last_text = display
|
||||
if done:
|
||||
if chat_id:
|
||||
_send_generated_files(chat_id, raw, receive_id_type="chat_id")
|
||||
else:
|
||||
_send_generated_files(open_id, raw)
|
||||
break
|
||||
if not user_tasks.get(open_id, {}).get("running", True):
|
||||
if chat_id:
|
||||
send_message(chat_id, "已停止", receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, "已停止")
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
print(f"[ERROR] run_agent 异常: {e}")
|
||||
traceback.print_exc()
|
||||
if chat_id:
|
||||
send_message(chat_id, f"错误: {str(e)}", receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, f"错误: {str(e)}")
|
||||
finally:
|
||||
user_tasks.pop(open_id, None)
|
||||
|
||||
threading.Thread(target=run_agent, daemon=True).start()
|
||||
|
||||
|
||||
def handle_command(open_id, cmd, chat_id=None):
|
||||
def _send_cmd_response(content):
|
||||
if chat_id:
|
||||
send_message(chat_id, content, receive_id_type="chat_id")
|
||||
else:
|
||||
send_message(open_id, content)
|
||||
if cmd == "/stop":
|
||||
if open_id in user_tasks:
|
||||
user_tasks[open_id]["running"] = False
|
||||
agent.abort()
|
||||
_send_cmd_response("正在停止...")
|
||||
elif cmd == "/new":
|
||||
agent.abort()
|
||||
agent.history = []
|
||||
_send_cmd_response("已清空当前共享上下文")
|
||||
elif cmd == "/help":
|
||||
_send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/new - 开启新对话\n/help - 显示帮助")
|
||||
elif cmd == "/status":
|
||||
_send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}")
|
||||
elif cmd == "/restore":
|
||||
try:
|
||||
files = glob.glob("./temp/model_responses_*.txt")
|
||||
if not files:
|
||||
return _send_cmd_response("没有找到历史记录")
|
||||
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 = 0
|
||||
for u, r in zip(users, resps):
|
||||
u, r = u.strip(), r.strip()[:500]
|
||||
if u and r:
|
||||
agent.history.extend([f"[USER]: {u}", f"[Agent] {r}"])
|
||||
count += 1
|
||||
agent.abort()
|
||||
_send_cmd_response(f"已恢复 {count} 轮对话\n来源: {os.path.basename(latest)}\n(仅恢复上下文,请输入新问题继续)")
|
||||
except Exception as e:
|
||||
_send_cmd_response(f"恢复失败: {e}")
|
||||
else:
|
||||
_send_cmd_response(f"未知命令: {cmd}")
|
||||
|
||||
|
||||
def main():
|
||||
global client
|
||||
if not APP_ID or not APP_SECRET:
|
||||
print("错误: 请在 mykey.py 或 mykey.json 中配置 fs_app_id 和 fs_app_secret")
|
||||
sys.exit(1)
|
||||
client = create_client()
|
||||
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build()
|
||||
cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO)
|
||||
print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50)
|
||||
cli.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
121
frontends/qqapp.py
Normal file
121
frontends/qqapp.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import asyncio, os, sys, threading, time
|
||||
from collections import deque
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
import botpy
|
||||
from botpy.message import C2CMessage, GroupMessage
|
||||
except Exception:
|
||||
print("Please install qq-botpy to use QQ module: pip install qq-botpy")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
APP_ID = str(mykeys.get("qq_app_id", "") 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()}
|
||||
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
|
||||
SEQ_LOCK, MSG_SEQ = threading.Lock(), 1
|
||||
|
||||
|
||||
def _next_msg_seq():
|
||||
global MSG_SEQ
|
||||
with SEQ_LOCK:
|
||||
MSG_SEQ += 1
|
||||
return MSG_SEQ
|
||||
|
||||
|
||||
def _build_intents():
|
||||
try:
|
||||
return botpy.Intents(public_messages=True, direct_message=True)
|
||||
except Exception:
|
||||
intents = botpy.Intents.none() if hasattr(botpy.Intents, "none") else botpy.Intents()
|
||||
for attr in ("public_messages", "public_guild_messages", "direct_message", "direct_messages", "c2c_message", "c2c_messages", "group_at_message", "group_at_messages"):
|
||||
if hasattr(intents, attr):
|
||||
try:
|
||||
setattr(intents, attr, True)
|
||||
except Exception:
|
||||
pass
|
||||
return intents
|
||||
|
||||
|
||||
def _make_bot_class(app):
|
||||
class QQBot(botpy.Client):
|
||||
def __init__(self):
|
||||
super().__init__(intents=_build_intents(), ext_handlers=False)
|
||||
|
||||
async def on_ready(self):
|
||||
print(f"[QQ] bot ready: {getattr(getattr(self, 'robot', None), 'name', 'QQBot')}")
|
||||
|
||||
async def on_c2c_message_create(self, message: C2CMessage):
|
||||
await app.on_message(message, is_group=False)
|
||||
|
||||
async def on_group_at_message_create(self, message: GroupMessage):
|
||||
await app.on_message(message, is_group=True)
|
||||
|
||||
async def on_direct_message_create(self, message):
|
||||
await app.on_message(message, is_group=False)
|
||||
|
||||
return QQBot
|
||||
|
||||
|
||||
class QQApp(AgentChatMixin):
|
||||
label, source, split_limit = "QQ", "qq", 1500
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
self.client = None
|
||||
|
||||
async def send_text(self, chat_id, content, *, msg_id=None, is_group=False):
|
||||
if not self.client:
|
||||
return
|
||||
api = self.client.api.post_group_message if is_group else self.client.api.post_c2c_message
|
||||
key = "group_openid" if is_group else "openid"
|
||||
for part in split_text(content, self.split_limit):
|
||||
await api(**{key: chat_id, "msg_type": 0, "content": part, "msg_id": msg_id, "msg_seq": _next_msg_seq()})
|
||||
|
||||
async def on_message(self, data, is_group=False):
|
||||
try:
|
||||
msg_id = getattr(data, "id", None)
|
||||
if msg_id in PROCESSED_IDS:
|
||||
return
|
||||
PROCESSED_IDS.append(msg_id)
|
||||
content = (getattr(data, "content", "") or "").strip()
|
||||
if not content:
|
||||
return
|
||||
author = getattr(data, "author", None)
|
||||
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 user_id) if is_group else user_id
|
||||
if not public_access(ALLOWED) and user_id not in ALLOWED:
|
||||
print(f"[QQ] unauthorized user: {user_id}")
|
||||
return
|
||||
print(f"[QQ] message from {user_id} ({'group' if is_group else 'c2c'}): {content}")
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(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:
|
||||
import traceback
|
||||
print("[QQ] handle_message error")
|
||||
traceback.print_exc()
|
||||
|
||||
async def start(self):
|
||||
self.client = _make_bot_class(self)()
|
||||
while True:
|
||||
try:
|
||||
print(f"[QQ] bot starting... {time.strftime('%m-%d %H:%M')}")
|
||||
await self.client.start(appid=APP_ID, secret=APP_SECRET)
|
||||
except Exception as e:
|
||||
print(f"[QQ] bot error: {e}")
|
||||
print("[QQ] reconnect in 5s...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19528, "QQ")
|
||||
require_runtime(agent, "QQ", qq_app_id=APP_ID, qq_app_secret=APP_SECRET)
|
||||
redirect_log(__file__, "qqapp.log", "QQ", ALLOWED)
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
asyncio.run(QQApp().start())
|
||||
105
frontends/stapp.py
Normal file
105
frontends/stapp.py
Normal file
@@ -0,0 +1,105 @@
|
||||
import os, sys
|
||||
if sys.stdout is None: sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None: sys.stderr = open(os.devnull, "w")
|
||||
try: sys.stdout.reconfigure(errors='replace')
|
||||
except: pass
|
||||
try: sys.stderr.reconfigure(errors='replace')
|
||||
except: pass
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
import streamlit as st
|
||||
import time, json, re, threading
|
||||
from agentmain import GeneraticAgent
|
||||
|
||||
st.set_page_config(page_title="Cowork", layout="wide")
|
||||
|
||||
@st.cache_resource
|
||||
def init():
|
||||
agent = GeneraticAgent()
|
||||
if agent.llmclient is None:
|
||||
st.error("⚠️ 未配置任何可用的 LLM 接口,请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。")
|
||||
st.stop()
|
||||
else:
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
return agent
|
||||
|
||||
agent = init()
|
||||
|
||||
st.title("🖥️ Cowork")
|
||||
|
||||
if 'autonomous_enabled' not in st.session_state: st.session_state.autonomous_enabled = False
|
||||
|
||||
@st.fragment
|
||||
def render_sidebar():
|
||||
current_idx = agent.llm_no
|
||||
st.caption(f"LLM Core: {current_idx}: {agent.get_llm_name()}", help="点击切换备用链路")
|
||||
last_reply_time = st.session_state.get('last_reply_time', 0)
|
||||
if last_reply_time > 0:
|
||||
st.caption(f"空闲时间:{int(time.time()) - last_reply_time}秒", help="当超过30分钟未收到回复时,系统会自动任务")
|
||||
if st.button("切换备用链路"):
|
||||
agent.next_llm()
|
||||
st.rerun(scope="fragment")
|
||||
if st.button("强行停止任务"):
|
||||
agent.abort()
|
||||
st.toast("已发送停止信号")
|
||||
st.rerun()
|
||||
if st.button("重新注入System Prompt"):
|
||||
agent.llmclient.last_tools = ''
|
||||
st.toast("下次将重新注入System Prompt")
|
||||
|
||||
st.divider()
|
||||
if st.button("开始空闲自主行动"):
|
||||
st.session_state.last_reply_time = int(time.time()) - 1800
|
||||
st.toast("已将上次回复时间设为1800秒前")
|
||||
st.rerun()
|
||||
if st.session_state.autonomous_enabled:
|
||||
if st.button("⏸️ 禁止自主行动"):
|
||||
st.session_state.autonomous_enabled = False
|
||||
st.toast("⏸️ 已禁止自主行动")
|
||||
st.rerun()
|
||||
st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行")
|
||||
else:
|
||||
if st.button("▶️ 允许自主行动", type="primary"):
|
||||
st.session_state.autonomous_enabled = True
|
||||
st.toast("✅ 已允许自主行动")
|
||||
st.rerun()
|
||||
st.caption("🔴 自主行动已停止")
|
||||
with st.sidebar: render_sidebar()
|
||||
|
||||
|
||||
def agent_backend_stream(prompt):
|
||||
display_queue = agent.put_task(prompt, source="user")
|
||||
try:
|
||||
while True:
|
||||
item = display_queue.get()
|
||||
if 'next' in item: yield item['next']
|
||||
if 'done' in item:
|
||||
yield item['done']; break
|
||||
finally:
|
||||
agent.abort()
|
||||
|
||||
if "messages" not in st.session_state: 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)
|
||||
|
||||
# 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("请输入指令"):
|
||||
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("assistant"):
|
||||
message_placeholder = st.empty()
|
||||
response = ''
|
||||
for response in agent_backend_stream(prompt):
|
||||
message_placeholder.markdown(response + "▌", unsafe_allow_html=False)
|
||||
message_placeholder.markdown(response, unsafe_allow_html=False)
|
||||
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||
st.session_state.last_reply_time = int(time.time())
|
||||
|
||||
if st.session_state.autonomous_enabled:
|
||||
st.markdown(f"""<div id="last-reply-time" style="display:none">{st.session_state.get('last_reply_time', int(time.time()))}</div>""", unsafe_allow_html=True)
|
||||
|
||||
156
frontends/tgapp.py
Normal file
156
frontends/tgapp.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import os, sys, re, threading, asyncio, queue as Q, socket, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
try:
|
||||
from telegram import Update
|
||||
from telegram.ext import ApplicationBuilder, MessageHandler, CommandHandler, filters, ContextTypes
|
||||
from telegram.request import HTTPXRequest
|
||||
except:
|
||||
print("Please ask the agent install python-telegram-bot to use telegram module.")
|
||||
sys.exit(1)
|
||||
from llmcore import mykeys
|
||||
|
||||
agent = GeneraticAgent()
|
||||
agent.verbose = False
|
||||
ALLOWED = set(mykeys.get('tg_allowed_users', []))
|
||||
|
||||
_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'summary', 'tool_use')]
|
||||
_TAG_PATS.append(r'<file_content>.*?</file_content>')
|
||||
|
||||
def _clean(t):
|
||||
for p in _TAG_PATS:
|
||||
t = re.sub(p, '', t, flags=re.DOTALL)
|
||||
return re.sub(r'\n{3,}', '\n\n', t).strip() or '...'
|
||||
|
||||
import html as _html
|
||||
def _inline_md(s):
|
||||
s = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', s)
|
||||
s = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', s)
|
||||
s = re.sub(r'`([^`]+)`', r'<code>\1</code>', s)
|
||||
return s
|
||||
def _to_html(t):
|
||||
parts, pos = [], 0
|
||||
for m in re.finditer(r'(`{3,})(?:\w*\n)?([\s\S]*?)\1', t):
|
||||
parts.append(_inline_md(_html.escape(t[pos:m.start()])))
|
||||
parts.append('<pre><code>' + _html.escape(m.group(2)) + '</code></pre>')
|
||||
pos = m.end()
|
||||
parts.append(_inline_md(_html.escape(t[pos:])))
|
||||
return ''.join(parts)
|
||||
|
||||
async def _stream(dq, msg):
|
||||
last_text = ""
|
||||
while True:
|
||||
await asyncio.sleep(3)
|
||||
item = None
|
||||
try:
|
||||
while True: item = dq.get_nowait()
|
||||
except Q.Empty: pass
|
||||
if item is None: continue
|
||||
raw = item.get("done") or item.get("next", "")
|
||||
done = "done" in item
|
||||
show = _clean(raw)
|
||||
if len(show) > 4000:
|
||||
# freeze current msg, start a new one
|
||||
try: msg = await msg.reply_text("(continued...)")
|
||||
except Exception: pass
|
||||
last_text = ""
|
||||
show = show[-3900:]
|
||||
display = show if done else show + " ⏳"
|
||||
if display != last_text:
|
||||
try: await msg.edit_text(_to_html(display), parse_mode='HTML')
|
||||
except Exception:
|
||||
try: await msg.edit_text(display)
|
||||
except Exception: pass
|
||||
last_text = display
|
||||
if done:
|
||||
files = re.findall(r'\[FILE:([^\]]+)\]', show[-1000:])
|
||||
for fpath in files:
|
||||
if os.path.exists(fpath):
|
||||
if fpath.lower().endswith(('.png','.jpg','.jpeg','.gif','.webp')):
|
||||
try: await msg.reply_photo(open(fpath,'rb'))
|
||||
except Exception: pass
|
||||
else:
|
||||
try: await msg.reply_document(open(fpath,'rb'))
|
||||
except Exception: pass
|
||||
show = re.sub(r'\[FILE:[^\]]+\]', '', show)
|
||||
if show.strip():
|
||||
try: await msg.edit_text(_to_html(show), parse_mode='HTML')
|
||||
except Exception:
|
||||
try: await msg.edit_text(show)
|
||||
except Exception: pass
|
||||
break
|
||||
|
||||
async def handle_msg(update, ctx):
|
||||
uid = update.effective_user.id
|
||||
if ALLOWED and uid not in ALLOWED:
|
||||
return await update.message.reply_text("no")
|
||||
msg = await update.message.reply_text("thinking...")
|
||||
prompt = f"If you need to show files to user, use [FILE:filepath] in your response.\n\n{update.message.text}"
|
||||
dq = agent.put_task(prompt, source="telegram")
|
||||
task = asyncio.create_task(_stream(dq, msg))
|
||||
ctx.user_data['stream_task'] = task
|
||||
|
||||
async def cmd_abort(update, ctx):
|
||||
agent.abort()
|
||||
task = ctx.user_data.get('stream_task')
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
await update.message.reply_text("Aborted")
|
||||
|
||||
async def cmd_llm(update, ctx):
|
||||
args = (update.message.text or '').split()
|
||||
if len(args) > 1:
|
||||
try:
|
||||
n = int(args[1])
|
||||
agent.next_llm(n)
|
||||
await update.message.reply_text(f"Switched to [{agent.llm_no}] {agent.get_llm_name()}")
|
||||
except (ValueError, IndexError):
|
||||
await update.message.reply_text(f"Usage: /llm <0-{len(agent.list_llms())-1}>")
|
||||
else:
|
||||
lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()]
|
||||
await update.message.reply_text("LLMs:\n" + "\n".join(lines))
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Single instance lock using socket
|
||||
try:
|
||||
_lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); _lock_sock.bind(('127.0.0.1', 19527))
|
||||
except OSError:
|
||||
print('[Telegram] Another instance is already running, skiping...')
|
||||
sys.exit(1)
|
||||
if not ALLOWED:
|
||||
print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.')
|
||||
sys.exit(1)
|
||||
_logf = open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp', 'tgapp.log'), 'a', encoding='utf-8', buffering=1)
|
||||
sys.stdout = sys.stderr = _logf
|
||||
print('[NEW] New process starting, the above are history infos ...')
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
proxy = mykeys.get('proxy', 'http://127.0.0.1:2082')
|
||||
print('proxy:', proxy)
|
||||
|
||||
async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):
|
||||
print(f"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}", flush=True)
|
||||
|
||||
while True:
|
||||
try:
|
||||
print(f"TG bot starting... {time.strftime('%m-%d %H:%M')}")
|
||||
# Recreate request and app objects on each restart to avoid stale connections
|
||||
request = HTTPXRequest(proxy=proxy, read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)
|
||||
app = (ApplicationBuilder()
|
||||
.token(mykeys['tg_bot_token'])
|
||||
.request(request)
|
||||
.get_updates_request(request)
|
||||
.build())
|
||||
app.add_handler(CommandHandler("stop", cmd_abort))
|
||||
app.add_handler(CommandHandler("llm", cmd_llm))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg))
|
||||
app.add_error_handler(_error_handler)
|
||||
|
||||
app.run_polling(
|
||||
drop_pending_updates=True,
|
||||
poll_interval=1.0,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[{time.strftime('%m-%d %H:%M')}] polling crashed: {e}", flush=True)
|
||||
time.sleep(10)
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
108
frontends/wecomapp.py
Normal file
108
frontends/wecomapp.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import asyncio, os, sys, threading
|
||||
from collections import deque
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from agentmain import GeneraticAgent
|
||||
from chatapp_common import AgentChatMixin, ensure_single_instance, public_access, redirect_log, require_runtime, split_text
|
||||
from llmcore import mykeys
|
||||
|
||||
try:
|
||||
from wecom_aibot_sdk import WSClient, generate_req_id
|
||||
except Exception:
|
||||
print("Please install wecom_aibot_sdk to use WeCom: pip install wecom_aibot_sdk")
|
||||
sys.exit(1)
|
||||
|
||||
agent = GeneraticAgent(); agent.verbose = False
|
||||
BOT_ID = str(mykeys.get("wecom_bot_id", "") or "").strip()
|
||||
SECRET = str(mykeys.get("wecom_secret", "") 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()}
|
||||
PROCESSED_IDS, USER_TASKS = deque(maxlen=1000), {}
|
||||
|
||||
|
||||
class WeComApp(AgentChatMixin):
|
||||
label, source, split_limit = "WeCom", "wecom", 1200
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(agent, USER_TASKS)
|
||||
self.client, self.chat_frames = None, {}
|
||||
|
||||
async def send_text(self, chat_id, content):
|
||||
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
|
||||
frame = self.chat_frames[chat_id]
|
||||
for part in split_text(content, self.split_limit):
|
||||
await self.client.reply_stream(frame, generate_req_id("stream"), part, finish=True)
|
||||
|
||||
async def on_text(self, frame):
|
||||
try:
|
||||
body = frame.body if hasattr(frame, "body") else frame.get("body", frame) if isinstance(frame, dict) else {}
|
||||
if not isinstance(body, dict):
|
||||
return
|
||||
msg_id = body.get("msgid") or f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
|
||||
if msg_id in PROCESSED_IDS:
|
||||
return
|
||||
PROCESSED_IDS.append(msg_id)
|
||||
from_info = body.get("from", {}) if isinstance(body.get("from", {}), dict) else {}
|
||||
sender_id = str(from_info.get("userid", "") or "unknown")
|
||||
chat_id = str(body.get("chatid", "") or sender_id)
|
||||
content = str((body.get("text", {}) or {}).get("content", "") or "").strip()
|
||||
if not content:
|
||||
return
|
||||
if not public_access(ALLOWED) and sender_id not in ALLOWED:
|
||||
print(f"[WeCom] unauthorized user: {sender_id}")
|
||||
return
|
||||
self.chat_frames[chat_id] = frame
|
||||
print(f"[WeCom] message from {sender_id}: {content}")
|
||||
if content.startswith("/"):
|
||||
return await self.handle_command(chat_id, content)
|
||||
asyncio.create_task(self.run_agent(chat_id, content))
|
||||
except Exception:
|
||||
import traceback
|
||||
print("[WeCom] handle_message error")
|
||||
traceback.print_exc()
|
||||
|
||||
async def on_enter_chat(self, frame):
|
||||
if WELCOME and self.client:
|
||||
try:
|
||||
await self.client.reply_welcome(frame, {"msgtype": "text", "text": {"content": WELCOME}})
|
||||
except Exception as e:
|
||||
print(f"[WeCom] welcome error: {e}")
|
||||
|
||||
async def on_connected(self, frame):
|
||||
print("[WeCom] connected")
|
||||
|
||||
async def on_authenticated(self, frame):
|
||||
print("[WeCom] authenticated")
|
||||
|
||||
async def on_disconnected(self, frame):
|
||||
print("[WeCom] disconnected")
|
||||
|
||||
async def on_error(self, frame):
|
||||
print(f"[WeCom] error: {frame}")
|
||||
|
||||
async def start(self):
|
||||
self.client = WSClient({"bot_id": BOT_ID, "secret": SECRET, "reconnect_interval": 1000, "max_reconnect_attempts": -1, "heartbeat_interval": 30000})
|
||||
for event, handler in {
|
||||
"connected": self.on_connected,
|
||||
"authenticated": self.on_authenticated,
|
||||
"disconnected": self.on_disconnected,
|
||||
"error": self.on_error,
|
||||
"message.text": self.on_text,
|
||||
"event.enter_chat": self.on_enter_chat,
|
||||
}.items():
|
||||
self.client.on(event, handler)
|
||||
print("[WeCom] bot starting...")
|
||||
await self.client.connect_async()
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_LOCK_SOCK = ensure_single_instance(19529, "WeCom")
|
||||
require_runtime(agent, "WeCom", wecom_bot_id=BOT_ID, wecom_secret=SECRET)
|
||||
redirect_log(__file__, "wecomapp.log", "WeCom", ALLOWED)
|
||||
threading.Thread(target=agent.run, daemon=True).start()
|
||||
asyncio.run(WeComApp().start())
|
||||
Reference in New Issue
Block a user