feat: fsapp reconnect loop, tui branch/rewind, simphtml flow-child fix, sop updates
This commit is contained in:
1 parent
6738e17a1d
commit
1f99a5553b
5 files changed
+135
-70
No files matched your search
+21
-1
@@ -257,6 +257,7 @@ def _card(text):
|
||||
|
||||
|
||||
def _send_raw(receive_id, payload, msg_type, rtype):
|
||||
try:
|
||||
body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
|
||||
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
|
||||
).build()
|
||||
@@ -264,6 +265,8 @@ def _send_raw(receive_id, payload, msg_type, rtype):
|
||||
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,6 +275,7 @@ def _patch_card(message_id, card_json):
|
||||
|
||||
|
||||
def _patch_card_result(message_id, card_json):
|
||||
try:
|
||||
body = PatchMessageRequest.builder().message_id(message_id).request_body(
|
||||
PatchMessageRequestBody.builder().content(card_json).build()
|
||||
).build()
|
||||
@@ -280,6 +284,9 @@ def _patch_card_result(message_id, card_json):
|
||||
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)
|
||||
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__":
|
||||
|
||||
@@ -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:
|
||||
|
||||
+96
-64
@@ -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) ...\n<summary>demo planning</summary>\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))
|
||||
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 <n> to truncate history\n"
|
||||
"/switch <id|name> - 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 <n> to rewind n turns (1 = undo last turn).")
|
||||
self._system("\n".join(lines))
|
||||
return
|
||||
# /rewind <n>: truncate last n turns
|
||||
try:
|
||||
n = int(args[0])
|
||||
except ValueError:
|
||||
self._system("Usage: /rewind <n> (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 <id|name>")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
## 限制(isTrusted)
|
||||
- JS事件`isTrusted=false`,敏感操作(如文件上传/部分按钮)可能被拦截;这类场景首选**CDP桥**
|
||||
- ⚠JS点击按钮打不开新tab→可能是浏览器弹窗拦截,换CDP点击试试
|
||||
- 文件上传:JS无法填充`<input type=file>`;首选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;
|
||||
|
||||
+5
-2
@@ -83,8 +83,11 @@ function createEnhancedDOMCopy() {
|
||||
const hasVisualBg = bgAlpha > 0.1 || style.backgroundImage !== 'none' || (style.backdropFilter && style.backdropFilter !== 'none') || style.boxShadow !== 'none';
|
||||
|
||||
if (!hasVisualBg && childrenInfos.length > 0) {
|
||||
// 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 childrenInfos) {
|
||||
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);
|
||||
@@ -97,7 +100,7 @@ function createEnhancedDOMCopy() {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user