Merge remote-tracking branch 'origin/main' into feat/macos-desktop-app

This commit is contained in:
Huang richao
2026-04-24 17:53:18 +08:00
8 changed files with 647 additions and 129 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

View File

@@ -588,18 +588,32 @@ def handle_command(open_id, cmd, chat_id=None):
send_message(chat_id, content, receive_id_type="chat_id") send_message(chat_id, content, receive_id_type="chat_id")
else: else:
send_message(open_id, content) 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: if open_id in user_tasks:
user_tasks[open_id]["running"] = False user_tasks[open_id]["running"] = False
agent.abort() agent.abort()
_send_cmd_response("正在停止...") _send_cmd_response("正在停止...")
elif cmd == "/new": elif op == "/new":
_send_cmd_response(reset_conversation(agent)) _send_cmd_response(reset_conversation(agent))
elif cmd == "/help": elif op == "/help":
_send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/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 cmd == "/status": elif op == "/status":
_send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}") llm = agent.get_llm_name() if agent.llmclient else "未配置"
elif cmd == "/restore": _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: try:
restored_info, err = format_restore() restored_info, err = format_restore()
if err: if err:
@@ -610,7 +624,7 @@ def handle_command(open_id, cmd, chat_id=None):
_send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") _send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
except Exception as e: except Exception as e:
_send_cmd_response(f"恢复失败: {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)) _send_cmd_response(handle_continue_frontend(agent, cmd))
else: else:
_send_cmd_response(f"未知命令: {cmd}") _send_cmd_response(f"未知命令: {cmd}")

View File

@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QScrollArea, QFrame, QTextEdit, QStackedWidget, QScrollArea, QFrame, QTextEdit, QStackedWidget,
QListWidget, QListWidgetItem, QSizePolicy, QFileDialog, QListWidget, QListWidgetItem, QSizePolicy, QFileDialog,
QSplitter, QTextBrowser, QApplication, QMessageBox, QSplitter, QTextBrowser, QApplication, QMessageBox,
QMenu, QLineEdit,
) )
from PySide6.QtCore import ( from PySide6.QtCore import (
Qt, QTimer, QPoint, QPointF, QByteArray, QSize, Qt, QTimer, QPoint, QPointF, QByteArray, QSize,
@@ -251,7 +252,6 @@ class FloatingButton(QWidget):
# ── Toggle panel ────────────────────────────────────── # ── Toggle panel ──────────────────────────────────────
def _toggle(self): def _toggle(self):
from PySide6.QtCore import QDateTime
now = QDateTime.currentMSecsSinceEpoch() now = QDateTime.currentMSecsSinceEpoch()
if now - self._last_toggle_ms < 500: # 500 ms debounce if now - self._last_toggle_ms < 500: # 500 ms debounce
return return
@@ -319,9 +319,11 @@ _SVG_COPY = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2"
_SVG_REGEN = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>' _SVG_REGEN = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>'
_SVG_CHAT = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' _SVG_CHAT = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
_SVG_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>' _SVG_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
_SVG_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>'
_SVG_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>' _SVG_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>'
_SVG_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>' _SVG_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
_SVG_CLIP = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.446 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>' _SVG_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
_SVG_CLIP = _SVG_PLUS
_SVG_STOP = '<svg viewBox="0 0 24 24" fill="{c}" stroke="none"><rect width="10" height="10" x="7" y="7" rx="1.5" ry="1.5"/></svg>' _SVG_STOP = '<svg viewBox="0 0 24 24" fill="{c}" stroke="none"><rect width="10" height="10" x="7" y="7" rx="1.5" ry="1.5"/></svg>'
_SVG_RESET = _SVG_REGEN _SVG_RESET = _SVG_REGEN
_SVG_SAVE = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>' _SVG_SAVE = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>'
@@ -332,7 +334,6 @@ _SVG_FILE = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2"
_SVG_USER = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' _SVG_USER = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
_SVG_BOT = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M7 5H3"/></svg>' _SVG_BOT = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M7 5H3"/></svg>'
_SVG_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4Z"/></svg>' _SVG_SEND = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4Z"/></svg>'
_SVG_PLUS = '<svg viewBox="0 0 24 24" fill="none" stroke="{c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" x2="12" y1="5" y2="19"/><line x1="5" x2="19" y1="12" y2="12"/></svg>'
_MD_CSS = """ _MD_CSS = """
body { color: #e4e4e7; font-family: "Arial", "Microsoft YaHei", sans-serif; font-size: 13px; line-height: 1.6; font-weight: 400; } 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._label.setHtml(_md_to_html(text))
self._adjust_browser_height() 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
kw_esc = keyword.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
highlighted = escaped.replace(kw_esc, f'<span style="background: rgba(251,191,36,0.35); color: #fbbf24;">{kw_esc}</span>')
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): class _TabButton(QPushButton):
_STYLE = """ _STYLE = """
@@ -787,8 +830,7 @@ class ChatPanel(QWidget):
p = QPainter(self) p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.Antialiasing)
path = QPainterPath() path = QPainterPath()
path.addRoundedRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0, path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0)
20.0, 20.0)
grad = QLinearGradient(0, 0, 0, self.height()) grad = QLinearGradient(0, 0, 0, self.height())
grad.setColorAt(0.0, QColor(20, 20, 28, 228)) grad.setColorAt(0.0, QColor(20, 20, 28, 228))
grad.setColorAt(1.0, QColor(10, 10, 14, 242)) grad.setColorAt(1.0, QColor(10, 10, 14, 242))
@@ -798,8 +840,7 @@ class ChatPanel(QWidget):
def resizeEvent(self, event): def resizeEvent(self, event):
path = QPainterPath() path = QPainterPath()
path.addRoundedRect(0, 0, float(self.width()), float(self.height()), path.addRect(0, 0, float(self.width()), float(self.height()))
20.0, 20.0)
self.setMask(QRegion(path.toFillPolygon().toPolygon())) self.setMask(QRegion(path.toFillPolygon().toPolygon()))
super().resizeEvent(event) super().resizeEvent(event)
@@ -821,6 +862,7 @@ class ChatPanel(QWidget):
self._stack.addWidget(self._build_sop_page()) # 2 self._stack.addWidget(self._build_sop_page()) # 2
self._stack.addWidget(self._build_settings_page())# 3 self._stack.addWidget(self._build_settings_page())# 3
root.addWidget(self._stack) root.addWidget(self._stack)
root.addWidget(self._build_statusbar())
# Now that _stack exists, activate the first tab # Now that _stack exists, activate the first tab
self._switch_tab(0) self._switch_tab(0)
@@ -836,35 +878,94 @@ class ChatPanel(QWidget):
ly.setContentsMargins(16, 0, 10, 0) ly.setContentsMargins(16, 0, 10, 0)
ly.setSpacing(8) ly.setSpacing(8)
# Status dot # Search button
dot = QLabel("") search_btn = QPushButton()
dot.setStyleSheet(f"color: {C['green']}; font-size: 9px;") search_btn.setIcon(_svg_icon("search", _SVG_SEARCH, "#a1a1aa"))
dot.setFixedWidth(12) search_btn.setIconSize(QSize(16, 16))
ly.addWidget(dot) 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 # Search widget (hidden by default)
t = QLabel("GenericAgent") self._search_widget = QWidget()
t.setStyleSheet("color: #f4f4f5; font-weight: 700; font-size: 14px;") self._search_widget.hide()
ly.addWidget(t) sw_ly = QHBoxLayout(self._search_widget)
sw_ly.setContentsMargins(0, 0, 0, 0)
sw_ly.setSpacing(6)
# Model badge self._search_input = QLineEdit()
self._model_badge = _Badge(self._model_name()) self._search_input.setPlaceholderText("搜索当前对话和历史...")
ly.addWidget(self._model_badge) 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() close_search = QPushButton("×")
ly.addWidget(self._streaming_badge) 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() 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 button
close = QPushButton("×") close = QPushButton("\uE8BB")
close.setFixedSize(26, 26) close.setFixedSize(26, 26)
close.setCursor(QCursor(Qt.PointingHandCursor))
close.setStyleSheet(""" close.setStyleSheet("""
QPushButton { background: rgba(63,63,70,0.6); color: #a1a1aa; 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; } 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) ly.addWidget(close)
# Drag # Drag
@@ -873,6 +974,111 @@ class ChatPanel(QWidget):
bar.mouseReleaseEvent = self._tb_release bar.mouseReleaseEvent = self._tb_release
return bar 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): def _tb_press(self, e):
if e.button() == Qt.LeftButton: if e.button() == Qt.LeftButton:
self._drag_pos = e.globalPosition().toPoint() - self.pos() self._drag_pos = e.globalPosition().toPoint() - self.pos()
@@ -884,6 +1090,68 @@ class ChatPanel(QWidget):
def _tb_release(self, _e): def _tb_release(self, _e):
self._drag_pos = None 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 ─────────────────────────────────────────────────────────────── # ── tab bar ───────────────────────────────────────────────────────────────
def _build_tabbar(self) -> QWidget: def _build_tabbar(self) -> QWidget:
bar = QWidget() bar = QWidget()
@@ -931,6 +1199,9 @@ class ChatPanel(QWidget):
self._stack.setCurrentIndex(idx) self._stack.setCurrentIndex(idx)
for i, btn in enumerate(self._tabs): for i, btn in enumerate(self._tabs):
btn.setChecked(i == idx) btn.setChecked(i == idx)
# 切换标签时关闭搜索框
if hasattr(self, '_search_visible') and self._search_visible:
self._hide_search()
if idx == 1: if idx == 1:
self._refresh_history() self._refresh_history()
if idx == 2: if idx == 2:
@@ -977,7 +1248,7 @@ class ChatPanel(QWidget):
wrap = QWidget() wrap = QWidget()
wrap.setStyleSheet("background: transparent;") wrap.setStyleSheet("background: transparent;")
ly = QVBoxLayout(wrap) ly = QVBoxLayout(wrap)
ly.setContentsMargins(20, 6, 20, 14) ly.setContentsMargins(20, 6, 20, 0)
ly.setSpacing(0) ly.setSpacing(0)
self._chips_row = QWidget() self._chips_row = QWidget()
@@ -1006,7 +1277,7 @@ class ChatPanel(QWidget):
self._input = QTextEdit() self._input = QTextEdit()
self._input.setFixedHeight(64) self._input.setFixedHeight(64)
self._input.setPlaceholderText("给助手发送消息...") self._input.setPlaceholderText("给助手发送消息... Enter发送Shift+Enter换行")
self._input.setStyleSheet(f""" self._input.setStyleSheet(f"""
QTextEdit {{ QTextEdit {{
background: transparent; color: {C['text']}; background: transparent; color: {C['text']};
@@ -1294,6 +1565,9 @@ class ChatPanel(QWidget):
ok = False ok = False
try: try:
reply = backend.ask("你好", stream=False) 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 "" text = str(reply).strip() if reply else ""
ok = len(text) > 0 and not text.startswith("Error") and not text.startswith("[") 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]}") 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") != "你好"] backend.raw_msgs = [m for m in backend.raw_msgs if m.get("prompt") != "你好"]
self._health_results[idx] = ok 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): def eventFilter(self, obj, event):
from PySide6.QtCore import QEvent if event.type() == QEvent.KeyPress:
if obj is self._input and event.type() == QEvent.KeyPress: if obj is self._search_input and event.key() == Qt.Key_Escape:
if event.key() in (Qt.Key_Return, Qt.Key_Enter): 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): if not (event.modifiers() & Qt.ShiftModifier):
self._handle_send() self._handle_send()
return True 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) return super().eventFilter(obj, event)
def _on_text_changed(self): def _on_text_changed(self):
@@ -1480,10 +1760,8 @@ class ChatPanel(QWidget):
def _scroll_bottom(self): def _scroll_bottom(self):
if self._user_scrolled_up: if self._user_scrolled_up:
return return
QTimer.singleShot(60, lambda: ( QTimer.singleShot(60, lambda: self._scroll.verticalScrollBar().setValue(
self._scroll.verticalScrollBar().setValue( self._scroll.verticalScrollBar().maximum()
self._scroll.verticalScrollBar().maximum()
)
)) ))
# ── inject (autonomous mode) ─────────────────────────────────────────────── # ── inject (autonomous mode) ───────────────────────────────────────────────
@@ -1513,6 +1791,9 @@ class ChatPanel(QWidget):
self._rebuild_messages() self._rebuild_messages()
self._switch_tab(0) self._switch_tab(0)
self._update_token_usage() 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): def _delete_selected(self):
item = self._hist_list.currentItem() item = self._hist_list.currentItem()
@@ -1551,7 +1832,7 @@ class ChatPanel(QWidget):
def _refresh_sop(self): def _refresh_sop(self):
self._sop_list.clear() self._sop_list.clear()
file_icon = _svg_icon("sop_file_item", _SVG_FILE, C["muted"]) 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) name = os.path.basename(path)
size = os.path.getsize(path) size = os.path.getsize(path)
it = QListWidgetItem(name) it = QListWidgetItem(name)
@@ -1589,10 +1870,6 @@ class ChatPanel(QWidget):
self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl) self._msg_layout.insertWidget(self._msg_layout.count() - 1, lbl)
self._scroll_bottom() 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): def _do_stop(self):
self.agent.abort() self.agent.abort()
self._poll_timer.stop() self._poll_timer.stop()
@@ -1722,7 +1999,6 @@ def main():
_last_trigger = [0.0] _last_trigger = [0.0]
def idle_check(): def idle_check():
import time
if not panel.autonomous_enabled: if not panel.autonomous_enabled:
return return
now = time.time() now = time.time()

View File

@@ -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__)))) 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') _TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
try: try:
from telegram import BotCommand from telegram import BotCommand
from telegram.constants import ChatType, MessageLimit, ParseMode
from telegram.ext import ApplicationBuilder, MessageHandler, filters, ContextTypes from telegram.ext import ApplicationBuilder, MessageHandler, filters, ContextTypes
from telegram.helpers import escape_markdown
from telegram.request import HTTPXRequest from telegram.request import HTTPXRequest
except: except:
print("Please ask the agent install python-telegram-bot to use telegram module.") print("Please ask the agent install python-telegram-bot to use telegram module.")
sys.exit(1) 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 continue_cmd import handle_frontend_command, reset_conversation
from llmcore import mykeys from llmcore import mykeys
agent = GeneraticAgent() agent = GeneraticAgent()
agent.verbose = False agent.verbose = False
agent.inc_out = True
ALLOWED = set(mykeys.get('tg_allowed_users', [])) ALLOWED = set(mykeys.get('tg_allowed_users', []))
_TAG_PATS = [r'<' + t + r'>.*?</' + t + r'>' for t in ('thinking', 'summary', 'tool_use')] _DRAFT_HINT = "thinking..."
_TAG_PATS.append(r'<file_content>.*?</file_content>') _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"|(?<!\*)\*(?!\*)([^\n]+?)(?<!\*)\*(?!\*)",
re.DOTALL,
)
def _clean(t): def _make_draft_id():
for p in _TAG_PATS: return random.randint(1, 2**31 - 1)
t = re.sub(p, '', t, flags=re.DOTALL)
return re.sub(r'\n{3,}', '\n\n', t).strip() or '...'
import html as _html def _visible_segments(text):
def _inline_md(s): text = (text or "").strip()
s = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', s) return split_text(text, _STREAM_SEGMENT_LIMIT) if text else []
s = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', s)
s = re.sub(r'`([^`]+)`', r'<code>\1</code>', s) def _resolve_files(paths):
return s files, seen = [], set()
def _to_html(t): 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 parts, pos = [], 0
for m in re.finditer(r'(`{3,})(?:\w*\n)?([\s\S]*?)\1', t): for match in _MD_TOKEN_RE.finditer(text):
parts.append(_inline_md(_html.escape(t[pos:m.start()]))) parts.append(escape_markdown(text[pos:match.start()], version=2))
parts.append('<pre><code>' + _html.escape(m.group(2)) + '</code></pre>') if match.group(1):
pos = m.end() lang = re.sub(r"[^A-Za-z0-9_+-]", "", match.group(2) or "")
parts.append(_inline_md(_html.escape(t[pos:]))) code = _escape_pre(match.group(3) or "")
return ''.join(parts) 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): async def _stream(dq, msg):
last_text = "" session = _TelegramStreamSession(msg)
while True: await session.prime()
await asyncio.sleep(3) try:
item = None while True:
try: try:
while True: item = dq.get_nowait() first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)
except Q.Empty: pass except Q.Empty:
if item is None: continue continue
raw = item.get("done") or item.get("next", "") items = [first]
done = "done" in item try:
show = _clean(raw) while True:
if len(show) > 4000: items.append(dq.get_nowait())
# freeze current msg, start a new one except Q.Empty:
try: msg = await msg.reply_text("(continued...)") pass
except Exception: pass done_item = next((item for item in items if "done" in item), None)
last_text = "" if done_item is not None:
show = show[-3900:] await session.finalize(done_item.get("done", ""))
display = show if done else show + "" break
if display != last_text: chunk = "".join(item.get("next", "") for item in items if item.get("next"))
try: await msg.edit_text(_to_html(display), parse_mode='HTML') if chunk:
except Exception: await session.add_chunk(chunk)
try: await msg.edit_text(display) except asyncio.CancelledError:
except Exception: pass await session.finish_with_notice("⏹️ 已停止")
last_text = display except Exception as exc:
if done: print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True)
files = re.findall(r'\[FILE:([^\]]+)\]', show[-1000:]) await session.finish_with_notice(f"❌ 输出失败: {exc}")
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
def _normalized_command(text): def _normalized_command(text):
parts = (text or '').strip().split(None, 1) parts = (text or "").strip().split(None, 1)
if not parts: if not parts:
return '' return ''
head = parts[0].lower() head = parts[0].lower()
@@ -105,10 +289,9 @@ async def handle_msg(update, ctx):
uid = update.effective_user.id uid = update.effective_user.id
if ALLOWED and uid not in ALLOWED: if ALLOWED and uid not in ALLOWED:
return await update.message.reply_text("no") return await update.message.reply_text("no")
msg = await update.message.reply_text("thinking...") prompt = f"{FILE_HINT}\n\n{update.message.text}"
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") 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 ctx.user_data['stream_task'] = task
async def cmd_abort(update, ctx): 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)) await file.download_to_drive(os.path.join(_TEMP_DIR, fpath))
caption = update.message.caption caption = update.message.caption
prompt = f"[TIPS] 收到图片temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到图片temp/{fpath},请等待下一步指令" 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") 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 ctx.user_data['stream_task'] = task
async def handle_command(update, ctx): async def handle_command(update, ctx):
@@ -189,7 +371,7 @@ if __name__ == '__main__':
sys.stdout = sys.stderr = _logf sys.stdout = sys.stderr = _logf
print('[NEW] New process starting, the above are history infos ...') print('[NEW] New process starting, the above are history infos ...')
threading.Thread(target=agent.run, daemon=True).start() 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) print('proxy:', proxy)
async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE): async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):

3
ga.py
View File

@@ -441,7 +441,8 @@ class GenericAgentHandler(BaseHandler):
当模型在一轮中未显式调用任何工具时,由引擎自动触发。 当模型在一轮中未显式调用任何工具时,由引擎自动触发。
二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。''' 二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。'''
content = getattr(response, 'content', '') or "" 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" yield "[Warn] LLM returned an empty response. Retrying...\n"
return StepOutcome({}, next_prompt="[System] Blank response, regenerate and tooluse") return StepOutcome({}, next_prompt="[System] Blank response, regenerate and tooluse")
if len(content) > 100 and ('未收到完整响应 !!!]' in content[-100:] or '!!!Error: [SSL:' in content[-100:]): if len(content) > 100 and ('未收到完整响应 !!!]' in content[-100:] or '!!!Error: [SSL:' in content[-100:]):

View File

@@ -148,7 +148,7 @@ def _parse_claude_sse(resp_lines):
elif evt_type == "error": elif evt_type == "error":
err = evt.get("error", {}) err = evt.get("error", {})
emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) 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 warn:
if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]" if not got_message_stop and not stop_reason: warn = "\n\n[!!! 流异常中断,未收到完整响应 !!!]"
elif stop_reason == "max_tokens": warn = "\n\n[!!! Response truncated: max_tokens !!!]" 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": elif etype == "error":
err = evt.get("error", {}) err = evt.get("error", {})
emsg = err.get("message", str(err)) if isinstance(err, dict) else str(err) 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 break
elif etype == "response.completed": elif etype == "response.completed":
usage = evt.get("response", {}).get("usage", {}) 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 text = delta["content"]; content_text += text; yield text
for tc in (delta.get("tool_calls") or []): for tc in (delta.get("tool_calls") or []):
idx = tc.get("index", 0) idx = tc.get("index", 0)
if idx not in tc_buf: tc_buf[idx] = {"id": tc.get("id") or '', "name": "", "args": ""} has_name = bool(tc.get("function", {}).get("name"))
if tc.get("function", {}).get("name"): tc_buf[idx]["name"] = tc["function"]["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("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") usage = evt.get("usage")
if usage: _record_usage(usage, api_mode) if usage: _record_usage(usage, api_mode)
blocks = [] blocks = []
@@ -518,7 +522,7 @@ class BaseSession:
if block.get('type', '') == 'tool_use': if block.get('type', '') == 'tool_use':
tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})} tu = {'name': block.get('name', ''), 'arguments': block.get('input', {})}
yield f'<tool_use>{json.dumps(tu, ensure_ascii=False)}</tool_use>' yield f'<tool_use>{json.dumps(tu, ensure_ascii=False)}</tool_use>'
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())) return _ask_gen() if stream else ''.join(list(_ask_gen()))
class ClaudeSession(BaseSession): 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]}") 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 [] return (yield from _parse_claude_sse(r.iter_lines())) or []
except Exception as e: except Exception as e:
yield (err := f"Error: {e}") yield (err := f"!!!Error: {e}")
return [{"type": "text", "text": err}] return [{"type": "text", "text": err}]
def make_messages(self, raw_list): def make_messages(self, raw_list):
msgs = [{"role": m['role'], "content": list(m['content'])} for m in 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 "" elif b.get("type") == "thinking": yield ""
return content_blocks return content_blocks
except Exception as e: except Exception as e:
yield (err := f"Error: {e}") yield (err := f"!!!Error: {e}")
return [{"type": "text", "text": err}] return [{"type": "text", "text": err}]
def ask(self, msg): def ask(self, msg):
@@ -631,7 +635,7 @@ class NativeClaudeSession(BaseSession):
try: try:
while True: yield next(gen) while True: yield next(gen)
except StopIteration as e: content_blocks = e.value or [] 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}) self.history.append({"role": "assistant", "content": content_blocks})
text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"] text_parts = [b["text"] for b in content_blocks if b.get("type") == "text"]
content = "\n".join(text_parts).strip() content = "\n".join(text_parts).strip()
@@ -672,7 +676,7 @@ class MockFunction:
class MockToolCall: class MockToolCall:
def __init__(self, name, args, id=''): 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 self.function = MockFunction(name, arg_str); self.id = id
class MockResponse: class MockResponse:
@@ -887,7 +891,7 @@ class MixinSession:
return self._cur_idx return self._cur_idx
def _raw_ask(self, *args, **kwargs): def _raw_ask(self, *args, **kwargs):
base, n = self._pick(), len(self._sessions) 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): for attempt in range(self._retries + 1):
idx = (base + attempt) % n idx = (base + attempt) % n
gen = self._orig_raw_asks[idx](*args, **kwargs) gen = self._orig_raw_asks[idx](*args, **kwargs)

View File

@@ -37,6 +37,7 @@ fetch('PDF_URL').then(r=>r.blob()).then(b=>{
## Chrome后台标签节流 ## Chrome后台标签节流
- 后台标签中`setTimeout`被Chrome intensive throttling延迟到≥1min/次扩展脚本中避免依赖setTimeout轮询 - 后台标签中`setTimeout`被Chrome intensive throttling延迟到≥1min/次扩展脚本中避免依赖setTimeout轮询
- 某些SPA页面需CDP `Page.bringToFront`切到前台才会加载数据
## CDP桥(tmwd_cdp_bridge扩展) ⭐首选 ## CDP桥(tmwd_cdp_bridge扩展) ⭐首选
扩展路径:`assets/tmwd_cdp_bridge/`(需安装含debugger权限) 扩展路径:`assets/tmwd_cdp_bridge/`(需安装含debugger权限)

40
pyproject.toml Normal file
View File

@@ -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 = []