diff --git a/frontends/fsapp.py b/frontends/fsapp.py index 9dcabfe4..e42332ec 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -257,13 +257,16 @@ def _card(text): def _send_raw(receive_id, payload, msg_type, rtype): - body = CreateMessageRequest.builder().receive_id_type(rtype).request_body( - CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build() - ).build() - r = client.im.v1.message.create(body) - if r.success(): - return r.data.message_id if r.data else None - print(f"发送失败: {r.code}, {r.msg}") + try: + body = CreateMessageRequest.builder().receive_id_type(rtype).request_body( + CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build() + ).build() + r = client.im.v1.message.create(body) + if r.success(): + return r.data.message_id if r.data else None + print(f"发送失败: {r.code}, {r.msg}") + except Exception as e: + print(f"[ERROR] _send_raw 网络异常: {e}") return None @@ -272,14 +275,18 @@ def _patch_card(message_id, card_json): def _patch_card_result(message_id, card_json): - body = PatchMessageRequest.builder().message_id(message_id).request_body( - PatchMessageRequestBody.builder().content(card_json).build() - ).build() - r = client.im.v1.message.patch(body) - if not r.success(): - print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") - msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower() - return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in msg) + try: + body = PatchMessageRequest.builder().message_id(message_id).request_body( + PatchMessageRequestBody.builder().content(card_json).build() + ).build() + r = client.im.v1.message.patch(body) + if not r.success(): + print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") + msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower() + return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in msg) + except Exception as e: + print(f"[ERROR] _patch_card 网络异常: {e}") + return False, False def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"): @@ -680,9 +687,22 @@ def main(): sys.exit(1) client = create_client() handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build() - cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO) print("=" * 50 + "\n飞书 Agent 已启动(长连接模式)\n" + f"App ID: {APP_ID}\n等待消息...\n" + "=" * 50) - cli.start() + retry_delay = 5 + while True: + try: + cli = lark.ws.Client(APP_ID, APP_SECRET, event_handler=handler, log_level=lark.LogLevel.INFO) + cli.start() + except Exception as e: + print(f"[WARN] 飞书长连接断开或启动失败: {e}") + print(f"[INFO] {retry_delay}s 后重连...") + time.sleep(retry_delay) + retry_delay = min(retry_delay * 2, 120) + # 重连时刷新 client + try: + client = create_client() + except Exception: + pass if __name__ == "__main__": diff --git a/frontends/stapp.py b/frontends/stapp.py index 5f59c570..a5c61626 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -253,7 +253,6 @@ if prompt := st.chat_input("any task?"): with live.container(): render_segments([segs[i]]) if i < len(segs) - 1: live = st.empty() st.session_state.messages.append({"role": "assistant", "content": response}) - st.session_state.messages = st.session_state.messages[-50:] st.session_state.last_reply_time = int(time.time()) if st.session_state.autonomous_enabled: diff --git a/frontends/tuiapp.py b/frontends/tuiapp.py index 1d8b4e0f..998bdc1d 100644 --- a/frontends/tuiapp.py +++ b/frontends/tuiapp.py @@ -6,7 +6,6 @@ Run from the project root: Useful options: - python frontends/tuiapp.py --demo # no LLM/key usage; fake streaming agent for smoke tests python frontends/tuiapp.py --help MVP design notes: @@ -147,65 +146,11 @@ def parse_local_command(raw: str) -> tuple[str, list[str]] | None: name, *rest = text.split(maxsplit=1) cmd = name[1:].lower() args = rest[0].split() if rest else [] - if cmd in {"help", "status", "new", "switch", "sessions", "stop", "llm", "quit", "exit"}: + if cmd in {"help", "status", "new", "switch", "sessions", "stop", "llm", "branch", "rewind", "quit", "exit"}: return cmd, args return None -class DemoAgent: - """Small fake agent used by --demo and tests; mimics put_task/run/display_queue.""" - - def __init__(self) -> None: - self.task_queue: queue.Queue = queue.Queue() - self.is_running = False - self.stop_sig = False - self.inc_out = True - self.verbose = False - self._llm_no = 0 - - def run(self) -> None: - while True: - task = self.task_queue.get() - raw = task["query"] - dq = task["output"] - self.is_running = True - self.stop_sig = False - chunks = [ - "LLM Running (Turn 1) ...\ndemo planning\n", - f"Echo: {raw}\n", - "LLM Running (Turn 2) ...\nFinal demo answer.\n", - ] - full = "" - for chunk in chunks: - if self.stop_sig: - break - time.sleep(0.08) - full += chunk - dq.put({"next": chunk, "source": "demo"}) - if self.stop_sig: - full += "\n[Interrupted]" - dq.put({"done": full, "source": "demo"}) - self.is_running = False - self.task_queue.task_done() - - def put_task(self, query: str, source: str = "user", images: Optional[list[Any]] = None) -> queue.Queue: - dq: queue.Queue = queue.Queue() - self.task_queue.put({"query": query, "source": source, "images": images or [], "output": dq}) - return dq - - def abort(self) -> None: - self.stop_sig = True - - def list_llms(self) -> list[tuple[int, str, bool]]: - return [(0, "DemoAgent/demo", True)] - - def next_llm(self, n: int = -1) -> None: - self._llm_no = 0 - - def get_llm_name(self, b: Any = None, model: bool = False) -> str: - return "demo" - - def default_agent_factory() -> Any: from agentmain import GenericAgent @@ -237,13 +182,12 @@ class GenericAgentTUI(App[None]): Binding("ctrl+right", "next_session", "Next→", show=True, priority=True), ] - def __init__(self, agent_factory: Optional[AgentFactory] = None, *, demo: bool = False) -> None: + def __init__(self, agent_factory: Optional[AgentFactory] = None) -> None: super().__init__() - self.agent_factory: AgentFactory = agent_factory or (DemoAgent if demo else default_agent_factory) + self.agent_factory: AgentFactory = agent_factory or default_agent_factory self.sessions: dict[int, AgentSession] = {} self.current_id: Optional[int] = None self._ids = count(1) - self.demo = demo self.fold_mode: bool = True self._last_stream_refresh: float = 0.0 self._stream_throttle_ms: float = 0.15 # seconds between streaming UI refreshes @@ -255,7 +199,7 @@ class GenericAgentTUI(App[None]): with Vertical(id="main"): yield Static("", id="status") yield RichLog(id="log", wrap=True, highlight=True, markup=True) - yield Input(placeholder="Message, or /help /new name /switch 1 /sessions /stop /llm", id="prompt") + yield Input(placeholder="Message, or /help /new /branch /rewind /switch /stop /llm /resume", id="prompt") yield Footer() def on_mount(self) -> None: @@ -263,6 +207,10 @@ class GenericAgentTUI(App[None]): self._system("Welcome to GenericAgent TUI. Type /help for commands.") self.query_one("#prompt", Input).focus() + def on_resize(self, event) -> None: + narrow = self.size.width < 70 + self.query_one("#sidebar").styles.display = "none" if narrow else "block" + @property def current(self) -> AgentSession: if self.current_id is None: @@ -340,6 +288,8 @@ class GenericAgentTUI(App[None]): "sessions": self._cmd_sessions, "stop": self._cmd_stop, "llm": self._cmd_llm, + "branch": self._cmd_branch, + "rewind": self._cmd_rewind, "quit": lambda _args: self.exit(), "exit": lambda _args: self.exit(), } @@ -412,13 +362,18 @@ class GenericAgentTUI(App[None]): break else: session.messages.append(ChatMessage("assistant", text, task_id=task_id, done=done)) - self._refresh_all() + if agent_id == self.current_id: + self._refresh_all() + else: + self._refresh_sidebar() def _cmd_help(self, args: list[str]) -> None: self._system( "Commands:\n" "/help - show this help\n" "/new [name] - create and switch to a new agent session\n" + "/branch [name] - fork current session (copies LLM history + display)\n" + "/rewind - list rewindable turns; /rewind to truncate history\n" "/switch - switch active session\n" "/sessions - list sessions\n" "/status - show current/all status\n" @@ -434,6 +389,85 @@ class GenericAgentTUI(App[None]): session = self.add_session(name) self._system(f"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.") + def _cmd_branch(self, args: list[str]) -> None: + import copy + old_session = self.current + name = " ".join(args).strip() or f"{old_session.name}-branch" + new_session = self.add_session(name) + # Copy LLM backend history + try: + new_session.agent.llmclient.backend.history = copy.deepcopy( + old_session.agent.llmclient.backend.history + ) + except Exception as e: + self._system(f"Branch warning: failed to copy history: {e}") + return + # Copy TUI display messages + new_session.messages = copy.deepcopy(old_session.messages) + new_session.task_seq = old_session.task_seq + n = len(new_session.agent.llmclient.backend.history) + self._system(f"Branched from #{old_session.agent_id} → #{new_session.agent_id} ({n} messages inherited).") + + def _cmd_rewind(self, args: list[str]) -> None: + session = self.current + if session.status == "running": + self._system("Cannot rewind while running. /stop first.") + return + history = session.agent.llmclient.backend.history + # Find real user turn boundaries — skip tool_result messages + turns = [] # list of (index_in_history, preview_text) + for i, msg in enumerate(history): + if msg.get("role") != "user": + continue + content = msg.get("content") + # Pure string content is always a real user message + if isinstance(content, str): + turns.append((i, content[:60])) + continue + if isinstance(content, list): + # Skip if content is purely tool_result blocks + has_tool_result = any(b.get("type") == "tool_result" for b in content if isinstance(b, dict)) + if has_tool_result: + continue + texts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] + if texts and any(t.strip() for t in texts): + turns.append((i, (texts[0] or "")[:60])) + if not turns: + self._system("No rewindable turns in history.") + return + # Reverse numbering: 1 = most recent turn, 2 = second most recent, etc. + # /rewind without args: show list + if not args: + lines = [f"Rewindable turns ({len(turns)} total, showing last 10):"] + show = turns[-10:] + for offset, (_, preview) in enumerate(reversed(show), 1): + lines.append(f" {offset}) {preview!r}") + lines.append("/rewind to rewind n turns (1 = undo last turn).") + self._system("\n".join(lines)) + return + # /rewind : truncate last n turns + try: + n = int(args[0]) + except ValueError: + self._system("Usage: /rewind (1 = undo last turn)") + return + if n < 1 or n > len(turns): + self._system(f"Invalid: range is 1-{len(turns)}") + return + # cut_at = index of the n-th turn from the end + cut_at = turns[-n][0] + removed = len(history) - cut_at + history[:] = history[:cut_at] + # Sync TUI messages: keep only messages before the corresponding user message + real_user_indices = [i for i, msg in enumerate(session.messages) if msg.role == "user"] + if n <= len(real_user_indices): + cut_msg = real_user_indices[-n] + session.messages = session.messages[:cut_msg] + # Mark rewind in agentmain's working memory history + try: session.agent.history.append(f"[USER]: /rewind {n}") + except Exception: pass + self._system(f"Rewound {n} turn(s). Removed {removed} history entries.") + def _cmd_switch(self, args: list[str]) -> None: if not args: self._system("Usage: /switch ") @@ -568,9 +602,8 @@ class GenericAgentTUI(App[None]): model = session.agent.get_llm_name(model=True) except Exception: model = "unknown" - mode = "demo" if self.demo else "real" status.update( - f"[b]#{session.agent_id} {session.name}[/b] status={session.status} task={session.current_task_id} model={model} mode={mode}\n" + f"[b]#{session.agent_id} {session.name}[/b] status={session.status} task={session.current_task_id} model={model}\n" "Enter message or /help. Per-task queue streaming is enabled (inc_out=True)." ) @@ -625,13 +658,12 @@ class GenericAgentTUI(App[None]): def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Textual TUI for GenericAgent") - parser.add_argument("--demo", action="store_true", help="use a fake streaming agent; no LLM/API keys required") return parser def main(argv: Optional[list[str]] = None) -> int: args = build_arg_parser().parse_args(argv) - app = GenericAgentTUI(demo=args.demo) + app = GenericAgentTUI() app.run() return 0 diff --git a/memory/tmwebdriver_sop.md b/memory/tmwebdriver_sop.md index e0dee2e7..2a1cb451 100644 --- a/memory/tmwebdriver_sop.md +++ b/memory/tmwebdriver_sop.md @@ -11,7 +11,8 @@ ## 限制(isTrusted) - JS事件`isTrusted=false`,敏感操作(如文件上传/部分按钮)可能被拦截;这类场景首选**CDP桥** - ⚠JS点击按钮打不开新tab→可能是浏览器弹窗拦截,换CDP点击试试 -- 文件上传:JS无法填充``;首选CDP batch:getDocument→querySelector→DOM.setFileInputFiles,备选ljqCtrl物理点击 +- Vue3自定义组件(Select/Dropdown):⭐优先vnode实例调用(无视口限制)→见**vue3_component_sop**;CDP坐标点击仅适合选项少且可见的场景 +- 文件上传:⭐首选**DataTransfer API**(纯JS,无CDP依赖):`new File([content],name,{type}) → new DataTransfer().items.add(file) → input.files=dt.files → dispatch input+change`;CDP `DOM.setFileInputFiles` 在tmwd桥环境nodeId跨调用失效,不推荐;备选ljqCtrl物理点击 - 需转物理坐标时:`physX = (screenX + rect中心x) * dpr`,`physY = (screenY + chromeH + rect中心y) * dpr`;其中 `chromeH = outerHeight - innerHeight` ## 导航 @@ -73,10 +74,20 @@ web_execute_js script='{"cmd": "batch", "commands": [...]}' - ⚠tabId:CDP默认sender.tab.id(当前注入页),跨tab需显式tabId或先batch内tabs查 - ⭐跨tab无需前台:指定tabId即可操作后台标签页 -## CDP点击完整生命周期(未验证,BBS#23) +## CDP点击完整生命周期(✅已验证) - 通用点击需**三事件序列**:mouseMoved → mousePressed → mouseReleased(间隔50-100ms) - 省略mouseMoved会导致MUI Tooltip/Ant Design Dropdown等hover依赖组件失效 - ⚠autofill释放是特例,只需mousePressed即可(见下方autofill章节) +- ⭐**坐标系结论**:稳定状态下 CDP坐标 = `getBoundingClientRect()` 坐标,**无需修正** + - ⚠**首次attach陷阱**:CDP debugger首次attach时Chrome弹出infobar("正在受自动化控制",~20px高),页面内容被推下 + - 如果在attach前测量坐标、attach后发送点击 → 坐标偏移!(之前Currency下拉失败的根因) + - ✅**解决**:确保测量坐标在CDP已attach稳定之后(即infobar已出现后再getBoundingClientRect) + - 实践:首次CDP操作前先发一个无害的`mouseMoved(0,0)`预热,之后坐标系就稳定了 +- ⭐**下拉框(Vue3 oxd-select等)CDP操作流程**: + 1. 获取select元素rect → CDP点击打开下拉 + 2. 获取option元素rect → CDP点击选中(option是动态DOM,打开后才能测量) + - 已验证:CDP点击对自定义下拉框有效,无isTrusted问题 + - ⚠**限制**:选项多时底部option超出视口,CDP坐标够不着→此时应优先vnode方案(见vue3_component_sop) - 坐标修正(页面有transform:scale/zoom时): ```js var scale = window.visualViewport ? window.visualViewport.scale : 1; diff --git a/simphtml.py b/simphtml.py index c3f3db2e..cd4b8791 100644 --- a/simphtml.py +++ b/simphtml.py @@ -83,21 +83,24 @@ function createEnhancedDOMCopy() { const hasVisualBg = bgAlpha > 0.1 || style.backgroundImage !== 'none' || (style.backdropFilter && style.backdropFilter !== 'none') || style.boxShadow !== 'none'; if (!hasVisualBg && childrenInfos.length > 0) { - let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity; - for (const cInfo of childrenInfos) { - minL = Math.min(minL, cInfo.rect.left); - minT = Math.min(minT, cInfo.rect.top); - maxR = Math.max(maxR, cInfo.rect.right); - maxB = Math.max(maxB, cInfo.rect.bottom); + // Skip fixed/absolute children when computing parent's merged rect (they're out of flow) + const flowChildren = childrenInfos.filter(cInfo => cInfo.style && cInfo.style.position !== 'fixed' && cInfo.style.position !== 'absolute'); + if (flowChildren.length > 0) { + let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity; + for (const cInfo of flowChildren) { + minL = Math.min(minL, cInfo.rect.left); + minT = Math.min(minT, cInfo.rect.top); + maxR = Math.max(maxR, cInfo.rect.right); + maxB = Math.max(maxB, cInfo.rect.bottom); + } + info.rect = { left: minL, top: minT, right: maxR, bottom: maxB, width: maxR - minL, height: maxB - minT }; + info.area = info.rect.width * info.rect.height; + } else { + const maxC = childrenInfos.filter(i => i.isVisible).sort((a, b) => b.area - a.area)[0]; + if (maxC && maxC.area > 10000 && (!isVisible || maxC.area > info.area * 5)) info = maxC; } - info.rect = { left: minL, top: minT, right: maxR, bottom: maxB, width: maxR - minL, height: maxB - minT }; - info.area = info.rect.width * info.rect.height; - } else { - const maxC = childrenInfos.filter(i => i.isVisible).sort((a, b) => b.area - a.area)[0]; - if (maxC && maxC.area > 10000 && (!isVisible || maxC.area > info.area * 5)) info = maxC; } } - nodeInfo.set(clone, info); if (sourceNode.nodeType === 1 && sourceNode.tagName === 'DIV') { if (!hasValidChildren && !sourceNode.textContent.trim()) return null;