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;