Merge remote-tracking branch 'origin/main' into feat/macos-desktop-app
This commit is contained in:
BIN
assets/images/wechat_group11.jpg
Normal file
BIN
assets/images/wechat_group11.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
@@ -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}")
|
||||
|
||||
@@ -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 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_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_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_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_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>'
|
||||
@@ -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_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_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 = """
|
||||
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'<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):
|
||||
_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(
|
||||
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()
|
||||
|
||||
@@ -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'<file_content>.*?</file_content>')
|
||||
_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"|(?<!\*)\*(?!\*)([^\n]+?)(?<!\*)\*(?!\*)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def _clean(t):
|
||||
for p in _TAG_PATS:
|
||||
t = re.sub(p, '', t, flags=re.DOTALL)
|
||||
return re.sub(r'\n{3,}', '\n\n', t).strip() or '...'
|
||||
def _make_draft_id():
|
||||
return random.randint(1, 2**31 - 1)
|
||||
|
||||
import html as _html
|
||||
def _inline_md(s):
|
||||
s = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', s)
|
||||
s = re.sub(r'(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)', r'<i>\1</i>', s)
|
||||
s = re.sub(r'`([^`]+)`', r'<code>\1</code>', s)
|
||||
return s
|
||||
def _to_html(t):
|
||||
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('<pre><code>' + _html.escape(m.group(2)) + '</code></pre>')
|
||||
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)
|
||||
|
||||
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
|
||||
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:
|
||||
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'))
|
||||
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: 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)
|
||||
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):
|
||||
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):
|
||||
|
||||
3
ga.py
3
ga.py
@@ -441,7 +441,8 @@ class GenericAgentHandler(BaseHandler):
|
||||
当模型在一轮中未显式调用任何工具时,由引擎自动触发。
|
||||
二次确认仅在回复几乎只包含<thinking>/<summary>和一段大代码块时触发。'''
|
||||
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:]):
|
||||
|
||||
24
llmcore.py
24
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'<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()))
|
||||
|
||||
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)
|
||||
|
||||
@@ -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权限)
|
||||
|
||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal 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 = []
|
||||
Reference in New Issue
Block a user