diff --git a/assets/images/wechat_group11.jpg b/assets/images/wechat_group11.jpg new file mode 100644 index 0000000..d9a3069 Binary files /dev/null and b/assets/images/wechat_group11.jpg differ diff --git a/frontends/fsapp.py b/frontends/fsapp.py index 515b24e..cce08fb 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -588,18 +588,32 @@ def handle_command(open_id, cmd, chat_id=None): send_message(chat_id, content, receive_id_type="chat_id") else: send_message(open_id, content) - if cmd == "/stop": + parts = (cmd or "").split() + op = (parts[0] if parts else "").lower() + if op == "/stop": if open_id in user_tasks: user_tasks[open_id]["running"] = False agent.abort() _send_cmd_response("正在停止...") - elif cmd == "/new": + elif op == "/new": _send_cmd_response(reset_conversation(agent)) - elif cmd == "/help": - _send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/help - 显示帮助") - elif cmd == "/status": - _send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}") - elif cmd == "/restore": + elif op == "/help": + _send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/llm - 查看当前模型列表\n/llm [n] - 切换到第 n 个模型\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/help - 显示帮助") + elif op == "/status": + llm = agent.get_llm_name() if agent.llmclient else "未配置" + _send_cmd_response(f"状态: {'🔴 运行中' if agent.is_running else '🟢 空闲'}\nLLM: [{agent.llm_no}] {llm}") + elif op == "/llm": + if not agent.llmclient: + return _send_cmd_response("❌ 当前没有可用的 LLM 配置") + if len(parts) > 1: + try: + agent.next_llm(int(parts[1])) + return _send_cmd_response(f"✅ 已切换到 [{agent.llm_no}] {agent.get_llm_name()}") + except Exception: + return _send_cmd_response(f"用法: /llm <0-{len(agent.list_llms()) - 1}>") + lines = [f"{'→' if cur else ' '} [{i}] {name}" for i, name, cur in agent.list_llms()] + _send_cmd_response("LLMs:\n" + "\n".join(lines)) + elif op == "/restore": try: restored_info, err = format_restore() if err: @@ -610,7 +624,7 @@ def handle_command(open_id, cmd, chat_id=None): _send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") except Exception as e: _send_cmd_response(f"恢复失败: {e}") - elif cmd.startswith("/continue"): + elif op == "/continue" or cmd.startswith("/continue"): _send_cmd_response(handle_continue_frontend(agent, cmd)) else: _send_cmd_response(f"未知命令: {cmd}") diff --git a/frontends/qtapp.py b/frontends/qtapp.py index ce69c34..3d5de07 100644 --- a/frontends/qtapp.py +++ b/frontends/qtapp.py @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( QScrollArea, QFrame, QTextEdit, QStackedWidget, QListWidget, QListWidgetItem, QSizePolicy, QFileDialog, QSplitter, QTextBrowser, QApplication, QMessageBox, + QMenu, QLineEdit, ) from PySide6.QtCore import ( Qt, QTimer, QPoint, QPointF, QByteArray, QSize, @@ -251,7 +252,6 @@ class FloatingButton(QWidget): # ── Toggle panel ────────────────────────────────────── def _toggle(self): - from PySide6.QtCore import QDateTime now = QDateTime.currentMSecsSinceEpoch() if now - self._last_toggle_ms < 500: # 500 ms debounce return @@ -319,9 +319,11 @@ _SVG_COPY = '' _SVG_BOT = '' _SVG_SEND = '' -_SVG_PLUS = '' _MD_CSS = """ body { color: #e4e4e7; font-family: "Arial", "Microsoft YaHei", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; } @@ -702,6 +703,48 @@ class _MsgRow(QWidget): self._label.setHtml(_md_to_html(text)) self._adjust_browser_height() + def highlight(self, keyword: str): + """Apply highlight and return keyword's y position in document, or None.""" + if not keyword or not self._text: + return None + kw_lower = keyword.lower() + text_lower = self._text.lower() + if kw_lower not in text_lower: + return None + if self._role == "user": + escaped = self._text.replace("&", "&").replace("<", "<").replace(">", ">") + kw_esc = keyword.replace("&", "&").replace("<", "<").replace(">", ">") + highlighted = escaped.replace(kw_esc, f'{kw_esc}') + self._label.setText(highlighted) + self._label.adjustSize() + return 0 # plain text, keyword at top + else: + from PySide6.QtGui import QTextDocument, QTextCursor, QTextCharFormat + doc = self._label.document() + cursor = QTextCursor(doc) + flags = QTextDocument.FindFlags(0) + fmt = QTextCharFormat() + fmt.setBackground(QColor(251, 191, 36, 90)) + fmt.setForeground(QColor(251, 191, 36)) + keyword_y = None + while True: + cursor = doc.find(keyword, cursor, flags) + if cursor.isNull(): + break + cursor.mergeCharFormat(fmt) + if keyword_y is None: + keyword_y = self._label.cursorRect(cursor).y() + self._adjust_browser_height() + return keyword_y + + def clear_highlight(self): + if self._role == "user": + self._label.setText(self._text) + self._label.adjustSize() + else: + self._label.setHtml(_md_to_html(self._text)) + self._adjust_browser_height() + class _TabButton(QPushButton): _STYLE = """ @@ -787,8 +830,7 @@ class ChatPanel(QWidget): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) path = QPainterPath() - path.addRoundedRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0, - 20.0, 20.0) + path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0) grad = QLinearGradient(0, 0, 0, self.height()) grad.setColorAt(0.0, QColor(20, 20, 28, 228)) grad.setColorAt(1.0, QColor(10, 10, 14, 242)) @@ -798,8 +840,7 @@ class ChatPanel(QWidget): def resizeEvent(self, event): path = QPainterPath() - path.addRoundedRect(0, 0, float(self.width()), float(self.height()), - 20.0, 20.0) + path.addRect(0, 0, float(self.width()), float(self.height())) self.setMask(QRegion(path.toFillPolygon().toPolygon())) super().resizeEvent(event) @@ -821,6 +862,7 @@ class ChatPanel(QWidget): self._stack.addWidget(self._build_sop_page()) # 2 self._stack.addWidget(self._build_settings_page())# 3 root.addWidget(self._stack) + root.addWidget(self._build_statusbar()) # Now that _stack exists, activate the first tab self._switch_tab(0) @@ -836,35 +878,94 @@ class ChatPanel(QWidget): ly.setContentsMargins(16, 0, 10, 0) ly.setSpacing(8) - # Status dot - dot = QLabel("●") - dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") - dot.setFixedWidth(12) - ly.addWidget(dot) + # Search button + search_btn = QPushButton() + search_btn.setIcon(_svg_icon("search", _SVG_SEARCH, "#a1a1aa")) + search_btn.setIconSize(QSize(16, 16)) + search_btn.setFixedSize(26, 26) + search_btn.setCursor(QCursor(Qt.PointingHandCursor)) + search_btn.setStyleSheet(""" + QPushButton { background: transparent; border: none; border-radius: 13px; } + QPushButton:hover { background: rgba(63,63,70,0.6); } + """) + search_btn.clicked.connect(self._toggle_search) + self._search_btn = search_btn + ly.addWidget(search_btn) - # Title - t = QLabel("GenericAgent") - t.setStyleSheet("color: #f4f4f5; font-weight: 700; font-size: 14px;") - ly.addWidget(t) + # Search widget (hidden by default) + self._search_widget = QWidget() + self._search_widget.hide() + sw_ly = QHBoxLayout(self._search_widget) + sw_ly.setContentsMargins(0, 0, 0, 0) + sw_ly.setSpacing(6) - # Model badge - self._model_badge = _Badge(self._model_name()) - ly.addWidget(self._model_badge) + self._search_input = QLineEdit() + self._search_input.setPlaceholderText("搜索当前对话和历史...") + self._search_input.setFixedHeight(26) + self._search_input.setStyleSheet(f""" + QLineEdit {{ + background: rgba(32,32,38,0.9); + border: 1px solid {C['border'].name()}; + border-radius: 13px; + color: {C['text']}; + font-size: 13px; + padding: 0 10px; + }} + QLineEdit::placeholder {{ color: {C['muted']}; }} + """) + self._search_input.setFixedWidth(200) + self._search_input.textChanged.connect(self._on_search_changed) + self._search_input.installEventFilter(self) + sw_ly.addWidget(self._search_input) - self._streaming_badge = _StreamingBadge() - ly.addWidget(self._streaming_badge) + close_search = QPushButton("×") + close_search.setFixedSize(26, 26) + close_search.setCursor(QCursor(Qt.PointingHandCursor)) + close_search.setStyleSheet(""" + QPushButton { background: transparent; color: #71717a; border: none; font-size: 16px; } + QPushButton:hover { color: #a1a1aa; } + """) + close_search.clicked.connect(self._hide_search) + sw_ly.addWidget(close_search) + ly.addWidget(self._search_widget) ly.addStretch() + # Minimize button + mini = QPushButton("\uE949") + mini.setFixedSize(26, 26) + mini.setCursor(QCursor(Qt.PointingHandCursor)) + mini.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + mini.clicked.connect(self.hide) + ly.addWidget(mini) + + # Maximize button + maxi = QPushButton("\uE739") + maxi.setFixedSize(26, 26) + maxi.setCursor(QCursor(Qt.PointingHandCursor)) + maxi.setStyleSheet(""" + QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } + QPushButton:hover { background: rgba(63,63,70,0.9); color: white; } + """) + maxi.clicked.connect(self._toggle_maximize) + self._maxi_btn = maxi + ly.addWidget(maxi) + # Close button - close = QPushButton("×") + close = QPushButton("\uE8BB") close.setFixedSize(26, 26) + close.setCursor(QCursor(Qt.PointingHandCursor)) close.setStyleSheet(""" QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; - border: none; border-radius: 13px; font-size: 15px; font-weight: bold; } + border: none; border-radius: 13px; font-family: "Segoe MDL2 Assets"; font-size: 9px; } QPushButton:hover { background: rgba(220,38,38,0.85); color: white; } """) - close.clicked.connect(self.hide) + close.clicked.connect(lambda: (self.close(), QApplication.instance().quit())) ly.addWidget(close) # Drag @@ -873,6 +974,111 @@ class ChatPanel(QWidget): bar.mouseReleaseEvent = self._tb_release return bar + def _toggle_search(self): + if hasattr(self, "_search_visible") and self._search_visible: + self._hide_search() + else: + self._show_search() + + def _show_search(self): + self._search_visible = True + self._search_btn.setFixedSize(0, 0) + self._search_widget.show() + self._search_input.setFocus() + self._search_input.selectAll() + + def _hide_search(self): + self._search_visible = False + self._search_btn.setFixedSize(26, 26) + self._search_widget.hide() + self._search_input.clear() + self._clear_all_highlights() + if self._stack.currentIndex() == 1: + self._reset_history_items_style() + + def _hide_search_if_no_focus(self): + if not self._search_input.hasFocus(): + self._hide_search() + + def _on_search_changed(self, text): + if not text.strip(): + self._clear_all_highlights() + return + keyword = text.strip() + current_tab = self._stack.currentIndex() + + if current_tab == 0: + self._search_current_chat(keyword) + elif current_tab == 1: + self._search_history(keyword) + + def _clear_all_highlights(self): + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + w.clear_highlight() + + def _search_current_chat(self, keyword: str): + first_found = None + first_keyword_y = None + for i in range(self._msg_layout.count() - 1): + w = self._msg_layout.itemAt(i).widget() + if isinstance(w, _MsgRow): + if keyword.lower() in w._text.lower(): + kw_y = w.highlight(keyword) + if first_found is None: + first_found = w + first_keyword_y = kw_y + else: + w.clear_highlight() + # 滚动到第一个匹配项(使用关键词在文档内的实际位置) + if first_found: + self._scroll_to_widget(first_found, first_keyword_y or 0) + + def _scroll_to_widget(self, w, keyword_y=0): + self._user_scrolled_up = True + self._msg_container.layout().activate() + QApplication.processEvents() + + sb = self._scroll.verticalScrollBar() + vp_h = self._scroll.viewport().height() + keyword_screen_y = w.y() + keyword_y + target = keyword_screen_y - vp_h // 3 + target = max(0, min(target, sb.maximum())) + sb.setValue(target) + QApplication.processEvents() + self._scroll.viewport().repaint() + + def _search_history(self, keyword: str): + kw_lower = keyword.lower() + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + session = item.data(Qt.UserRole) + messages = session.get("messages", []) if session else [] + content_text = " ".join([m.get("content", "") for m in messages if isinstance(m.get("content"), str)]) + match = kw_lower in content_text.lower() + item.setHidden(not match) + if match: + item.setBackground(QColor(251, 191, 36, 50)) + item.setForeground(QColor(251, 191, 36)) + else: + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + + def _reset_history_items_style(self): + for i in range(self._hist_list.count()): + item = self._hist_list.item(i) + item.setHidden(False) + item.setBackground(QColor(0, 0, 0, 0)) + item.setForeground(QColor(255, 255, 255)) + w = self._hist_list.itemWidget(item) + if w: + w.setStyleSheet( + f"background: rgba(35,35,42,0.6); color: {C['text']};" + " border: 1px solid #3f3f46; border-radius: 8px;" + " padding: 8px 12px; margin: 2px 0;" + ) + def _tb_press(self, e): if e.button() == Qt.LeftButton: self._drag_pos = e.globalPosition().toPoint() - self.pos() @@ -884,6 +1090,68 @@ class ChatPanel(QWidget): def _tb_release(self, _e): self._drag_pos = None + def _toggle_maximize(self): + if self.isMaximized(): + self.showNormal() + self._maxi_btn.setText("☐") + else: + self.showMaximized() + self._maxi_btn.setText("❐") + + # ── status bar ───────────────────────────────────────────────────────────── + def _build_statusbar(self) -> QWidget: + bar = QWidget() + bar.setFixedHeight(24) + bar.setStyleSheet("background: transparent;") + ly = QHBoxLayout(bar) + ly.setContentsMargins(16, 0, 10, 0) + ly.setSpacing(8) + + # Status dot + dot = QLabel("●") + dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") + dot.setFixedWidth(12) + ly.addWidget(dot) + + # Model name (clickable to show model list) + self._model_badge = QLabel(self._model_name()) + self._model_badge.setStyleSheet("color: #a1a1aa; font-size: 11px;") + self._model_badge.setCursor(QCursor(Qt.PointingHandCursor)) + self._model_badge.mousePressEvent = lambda e: self._show_model_menu(e) + ly.addWidget(self._model_badge) + + self._streaming_badge = _StreamingBadge() + ly.addWidget(self._streaming_badge) + + ly.addStretch() + return bar + + def _show_model_menu(self, _e): + menu = QMenu(self._model_badge) + menu.setStyleSheet(f""" + QMenu {{ + background: {C['panel'].name()}; + border: 1px solid {C['border'].name()}; + padding: 4px 0; + }} + QMenu::item {{ + color: {C['text']}; + padding: 6px 20px 6px 12px; + font-size: 12px; + }} + QMenu::item:selected {{ + background: rgba(63,63,70,0.6); + }} + """) + for i, client in enumerate(self.agent.llmclients): + try: + name = client.name or "未知" + except Exception: + name = "未知" + act = menu.addAction(f"{name} #{i + 1}") + act.triggered.connect(lambda _, idx=i: self._do_switch_to(idx)) + menu.exec(QCursor.pos()) + # ── tab bar ─────────────────────────────────────────────────────────────── def _build_tabbar(self) -> QWidget: bar = QWidget() @@ -931,6 +1199,9 @@ class ChatPanel(QWidget): self._stack.setCurrentIndex(idx) for i, btn in enumerate(self._tabs): btn.setChecked(i == idx) + # 切换标签时关闭搜索框 + if hasattr(self, '_search_visible') and self._search_visible: + self._hide_search() if idx == 1: self._refresh_history() if idx == 2: @@ -977,7 +1248,7 @@ class ChatPanel(QWidget): wrap = QWidget() wrap.setStyleSheet("background: transparent;") ly = QVBoxLayout(wrap) - ly.setContentsMargins(20, 6, 20, 14) + ly.setContentsMargins(20, 6, 20, 0) ly.setSpacing(0) self._chips_row = QWidget() @@ -1006,7 +1277,7 @@ class ChatPanel(QWidget): self._input = QTextEdit() self._input.setFixedHeight(64) - self._input.setPlaceholderText("给助手发送消息...") + self._input.setPlaceholderText("给助手发送消息... Enter发送,Shift+Enter换行") self._input.setStyleSheet(f""" QTextEdit {{ background: transparent; color: {C['text']}; @@ -1294,6 +1565,9 @@ class ChatPanel(QWidget): ok = False try: reply = backend.ask("你好", stream=False) + # 兼容生成器函数(NativeClaudeSession.ask是生成器) + if hasattr(reply, '__iter__') and not isinstance(reply, str): + reply = ''.join(str(b) for b in reply if isinstance(b, str)) text = str(reply).strip() if reply else "" ok = len(text) > 0 and not text.startswith("Error") and not text.startswith("[") print(f"[HealthCheck] Backend #{idx} {type(backend).__name__}/{backend.model}: {'OK' if ok else 'FAIL'} -> {text[:60]}") @@ -1304,14 +1578,20 @@ class ChatPanel(QWidget): backend.raw_msgs = [m for m in backend.raw_msgs if m.get("prompt") != "你好"] self._health_results[idx] = ok - # ── event filter (Enter key in text edit) ───────────────────────────────── + # ── event filter (Enter key in text edit, Escape to close search) ────────── def eventFilter(self, obj, event): - from PySide6.QtCore import QEvent - if obj is self._input and event.type() == QEvent.KeyPress: - if event.key() in (Qt.Key_Return, Qt.Key_Enter): + if event.type() == QEvent.KeyPress: + if obj is self._search_input and event.key() == Qt.Key_Escape: + self._hide_search() + return True + if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter): if not (event.modifiers() & Qt.ShiftModifier): self._handle_send() return True + # 搜索框失焦时关闭搜索 + if event.type() == QEvent.FocusOut and obj is self._search_input: + # 延迟关闭,等待点击事件处理完毕 + QTimer.singleShot(50, self._hide_search_if_no_focus) return super().eventFilter(obj, event) def _on_text_changed(self): @@ -1480,10 +1760,8 @@ class ChatPanel(QWidget): def _scroll_bottom(self): if self._user_scrolled_up: return - QTimer.singleShot(60, lambda: ( - self._scroll.verticalScrollBar().setValue( - self._scroll.verticalScrollBar().maximum() - ) + QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue( + self._scroll.verticalScrollBar().maximum() )) # ── inject (autonomous mode) ─────────────────────────────────────────────── @@ -1513,6 +1791,9 @@ class ChatPanel(QWidget): self._rebuild_messages() self._switch_tab(0) self._update_token_usage() + search_text = self._search_input.text().strip() + if search_text: + QTimer.singleShot(50, lambda: self._search_current_chat(search_text)) def _delete_selected(self): item = self._hist_list.currentItem() @@ -1551,7 +1832,7 @@ class ChatPanel(QWidget): def _refresh_sop(self): self._sop_list.clear() file_icon = _svg_icon("sop_file_item", _SVG_FILE, C["muted"]) - for path in sorted(glob.glob("memory/*.md")): + for path in sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(__file__)), "memory", "*.md"))): name = os.path.basename(path) size = os.path.getsize(path) it = QListWidgetItem(name) @@ -1589,10 +1870,6 @@ class ChatPanel(QWidget): self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl) self._scroll_bottom() - def _do_switch_model(self): - nxt = (self.agent.llm_no + 1) % len(self.agent.llmclients) - self._do_switch_to(nxt) - def _do_stop(self): self.agent.abort() self._poll_timer.stop() @@ -1722,7 +1999,6 @@ def main(): _last_trigger = [0.0] def idle_check(): - import time if not panel.autonomous_enabled: return now = time.time() diff --git a/frontends/tgapp.py b/frontends/tgapp.py index 4d1324f..c222c6b 100644 --- a/frontends/tgapp.py +++ b/frontends/tgapp.py @@ -1,91 +1,275 @@ -import os, sys, re, threading, asyncio, queue as Q, socket, time +import os, sys, re, threading, asyncio, queue as Q, socket, time, random sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) _TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp') from agentmain import GeneraticAgent try: from telegram import BotCommand + from telegram.constants import ChatType, MessageLimit, ParseMode from telegram.ext import ApplicationBuilder, MessageHandler, filters, ContextTypes + from telegram.helpers import escape_markdown from telegram.request import HTTPXRequest except: print("Please ask the agent install python-telegram-bot to use telegram module.") sys.exit(1) -from chatapp_common import HELP_TEXT, TELEGRAM_MENU_COMMANDS, format_restore +from chatapp_common import ( + FILE_HINT, + HELP_TEXT, + TELEGRAM_MENU_COMMANDS, + clean_reply, + extract_files, + format_restore, + split_text, + strip_files, +) from continue_cmd import handle_frontend_command, reset_conversation from llmcore import mykeys agent = GeneraticAgent() agent.verbose = False +agent.inc_out = True ALLOWED = set(mykeys.get('tg_allowed_users', [])) -_TAG_PATS = [r'<' + t + r'>.*?' for t in ('thinking', 'summary', 'tool_use')] -_TAG_PATS.append(r'.*?') +_DRAFT_HINT = "thinking..." +_STREAM_SUFFIX = " ⏳" +_STREAM_SEGMENT_LIMIT = max(1200, MessageLimit.MAX_TEXT_LENGTH - 256) +_QUEUE_WAIT_SECONDS = 1 +_MD_TOKEN_RE = re.compile( + r"(`{3,})([A-Za-z0-9_+-]*)\n([\s\S]*?)\1" + r"|\[([^\]]+)\]\(([^)\n]+)\)" + r"|`([^`\n]+)`" + r"|\*\*([^\n]+?)\*\*" + r"|(?\1', s) - s = re.sub(r'(?\1', s) - s = re.sub(r'`([^`]+)`', r'\1', s) - return s -def _to_html(t): +def _visible_segments(text): + text = (text or "").strip() + return split_text(text, _STREAM_SEGMENT_LIMIT) if text else [] + +def _resolve_files(paths): + files, seen = [], set() + for fpath in paths: + if not os.path.isabs(fpath): + fpath = os.path.join(_TEMP_DIR, fpath) + if fpath in seen or not os.path.exists(fpath): + continue + files.append(fpath) + seen.add(fpath) + return files + + +def _escape_pre(text): + return escape_markdown(text or "", version=2, entity_type="pre") + +def _escape_code(text): + return escape_markdown(text or "", version=2, entity_type="code") + +def _escape_link_target(text): + return escape_markdown(text or "", version=2, entity_type="text_link") + +def _to_markdown_v2(text): + if not text: + return "" 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('
' + _html.escape(m.group(2)) + '
') - pos = m.end() - parts.append(_inline_md(_html.escape(t[pos:]))) - return ''.join(parts) + for match in _MD_TOKEN_RE.finditer(text): + parts.append(escape_markdown(text[pos:match.start()], version=2)) + if match.group(1): + lang = re.sub(r"[^A-Za-z0-9_+-]", "", match.group(2) or "") + code = _escape_pre(match.group(3) or "") + header = f"```{lang}\n" if lang else "```\n" + parts.append(f"{header}{code}\n```") + elif match.group(4) is not None: + label = escape_markdown(match.group(4), version=2) + target = _escape_link_target(match.group(5)) + parts.append(f"[{label}]({target})") + elif match.group(6) is not None: + parts.append(f"`{_escape_code(match.group(6))}`") + elif match.group(7) is not None: + parts.append(f"*{escape_markdown(match.group(7), version=2)}*") + elif match.group(8) is not None: + parts.append(f"_{escape_markdown(match.group(8), version=2)}_") + pos = match.end() + parts.append(escape_markdown(text[pos:], version=2)) + return "".join(parts) + +def _is_not_modified_error(exc): + return "not modified" in str(exc).lower() + +class _TelegramStreamSession: + def __init__(self, root_msg): + self.root_msg = root_msg + self.private_chat = getattr(getattr(root_msg, "chat", None), "type", "") == ChatType.PRIVATE + self.can_use_draft = self.private_chat + self.draft_id = _make_draft_id() + self.live_msg = None + self.raw_text = "" + self.files = [] + self.sent_segments = 0 + self.active_display = "" + + async def prime(self): + if self.can_use_draft and await self._send_draft(_DRAFT_HINT): + self.active_display = _DRAFT_HINT + return + await self._upsert_live_message(_DRAFT_HINT) + self.active_display = _DRAFT_HINT + + async def add_chunk(self, chunk): + if not chunk: + return + self.raw_text += chunk + await self._refresh(done=False, send_files=False) + + async def finalize(self, full_text=None, send_files=True): + if full_text is not None: + self.raw_text = full_text + await self._refresh(done=True, send_files=send_files) + + async def finish_with_notice(self, notice): + if self.raw_text.strip(): + await self.finalize(send_files=False) + await self._reply_text(notice) + return + if self.live_msg is not None: + await self._edit_text(self.live_msg, notice) + self.live_msg = None + self.active_display = "" + return + await self._reply_text(notice) + self.active_display = "" + + async def _refresh(self, done, send_files): + cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else "" + self.files = _resolve_files(extract_files(cleaned)) + body = strip_files(cleaned) + if done and not body and self.files: + body = "已生成附件" + elif done and not body: + body = "..." + segments = _visible_segments(body) + finalized_target = len(segments) if done else max(len(segments) - 1, 0) + while self.sent_segments < finalized_target: + await self._finalize_segment(segments[self.sent_segments]) + self.sent_segments += 1 + if done: + if send_files: + await self._send_files() + return + active_text = segments[-1] if segments else _DRAFT_HINT + await self._stream_active(active_text) + + async def _stream_active(self, text): + display = (text or _DRAFT_HINT).strip() or _DRAFT_HINT + if display != _DRAFT_HINT: + display = display + _STREAM_SUFFIX + if display == self.active_display: + return + if self.can_use_draft and await self._send_draft(display): + self.active_display = display + return + await self._upsert_live_message(display) + self.active_display = display + + async def _finalize_segment(self, text): + final_text = (text or "").strip() or "..." + if self.live_msg is not None: + await self._edit_text(self.live_msg, final_text) + self.live_msg = None + else: + await self._reply_text(final_text) + self.active_display = "" + if self.can_use_draft: + self.draft_id = _make_draft_id() + + async def _send_files(self): + for fpath in self.files: + if fpath.lower().endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")): + try: + with open(fpath, "rb") as fp: + await self.root_msg.reply_photo(fp) + except Exception: pass + else: + try: + with open(fpath, "rb") as fp: + await self.root_msg.reply_document(fp) + except Exception: pass + + async def _send_draft(self, text): + try: + await self.root_msg.reply_text_draft( + self.draft_id, + _to_markdown_v2(text), + parse_mode=ParseMode.MARKDOWN_V2, + ) + return True + except Exception as exc: + if _is_not_modified_error(exc): + return True + print(f"[TG draft fallback] {type(exc).__name__}: {exc}", flush=True) + self.can_use_draft = False + self.draft_id = _make_draft_id() + return False + + async def _reply_text(self, text): + markdown = _to_markdown_v2(text) + try: + return await self.root_msg.reply_text(markdown, parse_mode=ParseMode.MARKDOWN_V2) + except Exception as exc: + if _is_not_modified_error(exc): + return None + return await self.root_msg.reply_text(text) + + async def _edit_text(self, msg, text): + markdown = _to_markdown_v2(text) + try: + updated = await msg.edit_text(markdown, parse_mode=ParseMode.MARKDOWN_V2) + except Exception as exc: + if _is_not_modified_error(exc): + return msg + updated = await msg.edit_text(text) + return updated if hasattr(updated, "edit_text") else msg + + async def _upsert_live_message(self, text): + if self.live_msg is None: + self.live_msg = await self._reply_text(text) + else: + self.live_msg = await self._edit_text(self.live_msg, text) + 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 not os.path.isabs(fpath): fpath = os.path.join(_TEMP_DIR, fpath) - 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 + session = _TelegramStreamSession(msg) + await session.prime() + try: + while True: + try: + first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS) + except Q.Empty: + continue + items = [first] + try: + while True: + items.append(dq.get_nowait()) + except Q.Empty: + pass + done_item = next((item for item in items if "done" in item), None) + if done_item is not None: + await session.finalize(done_item.get("done", "")) + break + chunk = "".join(item.get("next", "") for item in items if item.get("next")) + if chunk: + await session.add_chunk(chunk) + except asyncio.CancelledError: + await session.finish_with_notice("⏹️ 已停止") + except Exception as exc: + print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True) + await session.finish_with_notice(f"❌ 输出失败: {exc}") + def _normalized_command(text): - parts = (text or '').strip().split(None, 1) + parts = (text or "").strip().split(None, 1) if not parts: return '' head = parts[0].lower() @@ -105,10 +289,9 @@ 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}" + prompt = f"{FILE_HINT}\n\n{update.message.text}" dq = agent.put_task(prompt, source="telegram") - task = asyncio.create_task(_stream(dq, msg)) + task = asyncio.create_task(_stream(dq, update.message)) ctx.user_data['stream_task'] = task async def cmd_abort(update, ctx): @@ -139,9 +322,8 @@ async def handle_photo(update, ctx): await file.download_to_drive(os.path.join(_TEMP_DIR, fpath)) caption = update.message.caption prompt = f"[TIPS] 收到图片temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到图片temp/{fpath},请等待下一步指令" - msg = await update.message.reply_text("thinking...") dq = agent.put_task(prompt, source="telegram") - task = asyncio.create_task(_stream(dq, msg)) + task = asyncio.create_task(_stream(dq, update.message)) ctx.user_data['stream_task'] = task async def handle_command(update, ctx): @@ -189,7 +371,7 @@ if __name__ == '__main__': 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') + proxy = mykeys.get('proxy', None) # set 'proxy' in mykey.py if needed, e.g. 'http://127.0.0.1:2082' print('proxy:', proxy) async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE): diff --git a/ga.py b/ga.py index 0aa87a0..869f8ee 100644 --- a/ga.py +++ b/ga.py @@ -441,7 +441,8 @@ class GenericAgentHandler(BaseHandler): 当模型在一轮中未显式调用任何工具时,由引擎自动触发。 二次确认仅在回复几乎只包含/和一段大代码块时触发。''' content = getattr(response, 'content', '') or "" - if not response or not content.strip(): + thinking = getattr(response, 'thinking', '') or "" + if not response or (not content.strip() and not thinking.strip()): yield "[Warn] LLM returned an empty response. Retrying...\n" return StepOutcome({}, next_prompt="[System] Blank response, regenerate and tooluse") if len(content) > 100 and ('未收到完整响应 !!!]' in content[-100:] or '!!!Error: [SSL:' in content[-100:]): diff --git a/llmcore.py b/llmcore.py index 675e4e2..2a84677 100644 --- a/llmcore.py +++ b/llmcore.py @@ -148,7 +148,7 @@ def _parse_claude_sse(resp_lines): elif evt_type == "error": err = evt.get("error", {}) emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) - warn = f"\n\n[SSE Error: {emsg}]"; break + warn = f"\n\n!!!Error: SSE {emsg}"; break if not warn: if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]" elif stop_reason == "max_tokens": warn = "\n\n[!!! Response truncated: max_tokens !!!]" @@ -211,7 +211,7 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"): elif etype == "error": err = evt.get("error", {}) emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) - if emsg: content_text += f"Error: {emsg}"; yield f"Error: {emsg}" + if emsg: content_text += f"!!!Error: {emsg}"; yield f"!!!Error: {emsg}" break elif etype == "response.completed": usage = evt.get("response", {}).get("usage", {}) @@ -243,9 +243,13 @@ def _parse_openai_sse(resp_lines, api_mode="chat_completions"): text = delta["content"]; content_text += text; yield text for tc in (delta.get("tool_calls") or []): idx = tc.get("index", 0) - if idx not in tc_buf: tc_buf[idx] = {"id": tc.get("id") or '', "name": "", "args": ""} - if tc.get("function", {}).get("name"): tc_buf[idx]["name"] = tc["function"]["name"] + has_name = bool(tc.get("function", {}).get("name")) + if idx not in tc_buf: + if has_name or not tc_buf: tc_buf[idx] = {"id": tc.get("id") or '', "name": "", "args": ""} + else: idx = max(tc_buf) + if has_name: tc_buf[idx]["name"] = tc["function"]["name"] if tc.get("function", {}).get("arguments"): tc_buf[idx]["args"] += tc["function"]["arguments"] + if tc.get("id") and not tc_buf[idx]["id"]: tc_buf[idx]["id"] = tc["id"] usage = evt.get("usage") if usage: _record_usage(usage, api_mode) blocks = [] @@ -518,7 +522,7 @@ class BaseSession: if block.get('type', '') == 'tool_use': tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})} yield f'{json.dumps(tu, ensure_ascii=False)}' - if not content.startswith("Error:"): self.history.append({"role": "assistant", "content": [{"type": "text", "text": content}]}) + if not content.startswith("!!!Error:"): self.history.append({"role": "assistant", "content": [{"type": "text", "text": content}]}) return _ask_gen() if stream else ''.join(list(_ask_gen())) class ClaudeSession(BaseSession): @@ -533,7 +537,7 @@ class ClaudeSession(BaseSession): if r.status_code != 200: raise Exception(f"HTTP {r.status_code} {r.content.decode('utf-8', errors='replace')[:500]}") return (yield from _parse_claude_sse(r.iter_lines())) or [] except Exception as e: - yield (err := f"Error: {e}") + yield (err := f"!!!Error: {e}") return [{"type": "text", "text": err}] def make_messages(self, raw_list): msgs = [{"role": m['role'], "content": list(m['content'])} for m in raw_list] @@ -617,7 +621,7 @@ class NativeClaudeSession(BaseSession): elif b.get("type") == "thinking": yield "" return content_blocks except Exception as e: - yield (err := f"Error: {e}") + yield (err := f"!!!Error: {e}") return [{"type": "text", "text": err}] def ask(self, msg): @@ -631,7 +635,7 @@ class NativeClaudeSession(BaseSession): try: while True: yield next(gen) except StopIteration as e: content_blocks = e.value or [] - if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get("text", "").startswith("Error:")): + if content_blocks and not (len(content_blocks) == 1 and content_blocks[0].get("text", "").startswith("!!!Error:")): self.history.append({"role": "assistant", "content": content_blocks}) text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"] content = "\n".join(text_parts).strip() @@ -672,7 +676,7 @@ class MockFunction: class MockToolCall: def __init__(self, name, args, id=''): - arg_str = json.dumps(args, ensure_ascii=False) if isinstance(args, dict) else args + arg_str = json.dumps(args, ensure_ascii=False) if isinstance(args, (dict, list)) else (args or '{}') self.function = MockFunction(name, arg_str); self.id = id class MockResponse: @@ -887,7 +891,7 @@ class MixinSession: return self._cur_idx def _raw_ask(self, *args, **kwargs): base, n = self._pick(), len(self._sessions) - test_error = lambda x: isinstance(x, str) and (x.startswith('Error:') or x.startswith('[Error:')) + test_error = lambda x: isinstance(x, str) and x.lstrip().startswith(('!!!Error:', '[Error:')) for attempt in range(self._retries + 1): idx = (base + attempt) % n gen = self._orig_raw_asks[idx](*args, **kwargs) diff --git a/memory/tmwebdriver_sop.md b/memory/tmwebdriver_sop.md index d758893..823f779 100644 --- a/memory/tmwebdriver_sop.md +++ b/memory/tmwebdriver_sop.md @@ -37,6 +37,7 @@ fetch('PDF_URL').then(r=>r.blob()).then(b=>{ ## Chrome后台标签节流 - 后台标签中`setTimeout`被Chrome intensive throttling延迟到≥1min/次,扩展脚本中避免依赖setTimeout轮询 +- 某些SPA页面需CDP `Page.bringToFront`切到前台才会加载数据 ## CDP桥(tmwd_cdp_bridge扩展) ⭐首选 扩展路径:`assets/tmwd_cdp_bridge/`(需安装,含debugger权限) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5ac0326 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +# AI install hint: choose deps by OS/env + needed ui/bot. do NOT install all. +# minimal core first; missing packages can be installed on demand. + +[project] +name = "genericagent" +version = "0.1.0" +description = "Minimalist self-evolving autonomous agent framework" +readme = "README.md" +requires-python = ">=3.10,<3.14" +license = { text = "MIT" } +dependencies = [ + "requests>=2.28", + "beautifulsoup4>=4.12", + "bottle>=0.12", + "simple-websocket-server>=0.4", +] + +[project.optional-dependencies] +# select extras, not all. match current task/env. +# examples: .[ui] for GUI, bot deps only if bot needed. +ui = [ + "streamlit>=1.28", + "pywebview>=4.0", +] +all-frontends = [ + "python-telegram-bot>=20.0", + "qq-botpy>=1.0", + "pycryptodome>=3.19", + "qrcode>=7.4", + "lark-oapi>=1.0", + "wecom-aibot-sdk>=1.0", + "dingtalk-stream>=0.20", +] + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = [] \ No newline at end of file