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_BOOK = ''
_SVG_GEAR = ''
-_SVG_CLIP = ''
+_SVG_PLUS = ''
+_SVG_CLIP = _SVG_PLUS
_SVG_STOP = ''
_SVG_RESET = _SVG_REGEN
_SVG_SAVE = ''
@@ -332,7 +334,6 @@ _SVG_FILE = ''
_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'>.*?' + t + r'>' for t in ('thinking', 'summary', 'tool_use')]
-_TAG_PATS.append(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):
当模型在一轮中未显式调用任何工具时,由引擎自动触发。
二次确认仅在回复几乎只包含