feat: fsapp reconnect loop, tui branch/rewind, simphtml flow-child fix, sop updates

This commit is contained in:
Liang Jiaqing committed 2026-05-08 15:48:16 +08:00
1 parent 6738e17a1d
commit 1f99a5553b
5 files changed
+162 -97

No files matched your search

+37 -17
View File
@@ -257,13 +257,16 @@ def _card(text):
def _send_raw(receive_id, payload, msg_type, rtype): def _send_raw(receive_id, payload, msg_type, rtype):
body = CreateMessageRequest.builder().receive_id_type(rtype).request_body( try:
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build() body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
).build() CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
r = client.im.v1.message.create(body) ).build()
if r.success(): r = client.im.v1.message.create(body)
return r.data.message_id if r.data else None if r.success():
print(f"发送失败: {r.code}, {r.msg}") 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 return None
@@ -272,14 +275,18 @@ def _patch_card(message_id, card_json):
def _patch_card_result(message_id, card_json): def _patch_card_result(message_id, card_json):
body = PatchMessageRequest.builder().message_id(message_id).request_body( try:
PatchMessageRequestBody.builder().content(card_json).build() body = PatchMessageRequest.builder().message_id(message_id).request_body(
).build() PatchMessageRequestBody.builder().content(card_json).build()
r = client.im.v1.message.patch(body) ).build()
if not r.success(): r = client.im.v1.message.patch(body)
print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}") if not r.success():
msg = f"{getattr(r, 'code', '')} {getattr(r, 'msg', '')}".lower() print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
return r.success(), ("230099" in msg or "11310" in msg or "element exceeds the limit" in 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"): 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) sys.exit(1)
client = create_client() client = create_client()
handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(handle_message).build() 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) 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__": if __name__ == "__main__":
-1
View File
@@ -253,7 +253,6 @@ if prompt := st.chat_input("any task?"):
with live.container(): render_segments([segs[i]]) with live.container(): render_segments([segs[i]])
if i < len(segs) - 1: live = st.empty() if i < len(segs) - 1: live = st.empty()
st.session_state.messages.append({"role": "assistant", "content": response}) 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()) st.session_state.last_reply_time = int(time.time())
if st.session_state.autonomous_enabled: if st.session_state.autonomous_enabled:
+97 -65
View File
@@ -6,7 +6,6 @@ Run from the project root:
Useful options: Useful options:
python frontends/tuiapp.py --demo # no LLM/key usage; fake streaming agent for smoke tests
python frontends/tuiapp.py --help python frontends/tuiapp.py --help
MVP design notes: MVP design notes:
@@ -147,65 +146,11 @@ def parse_local_command(raw: str) -> tuple[str, list[str]] | None:
name, *rest = text.split(maxsplit=1) name, *rest = text.split(maxsplit=1)
cmd = name[1:].lower() cmd = name[1:].lower()
args = rest[0].split() if rest else [] 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 cmd, args
return None 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: def default_agent_factory() -> Any:
from agentmain import GenericAgent from agentmain import GenericAgent
@@ -237,13 +182,12 @@ class GenericAgentTUI(App[None]):
Binding("ctrl+right", "next_session", "Next→", show=True, priority=True), 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__() 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.sessions: dict[int, AgentSession] = {}
self.current_id: Optional[int] = None self.current_id: Optional[int] = None
self._ids = count(1) self._ids = count(1)
self.demo = demo
self.fold_mode: bool = True self.fold_mode: bool = True
self._last_stream_refresh: float = 0.0 self._last_stream_refresh: float = 0.0
self._stream_throttle_ms: float = 0.15 # seconds between streaming UI refreshes self._stream_throttle_ms: float = 0.15 # seconds between streaming UI refreshes
@@ -255,7 +199,7 @@ class GenericAgentTUI(App[None]):
with Vertical(id="main"): with Vertical(id="main"):
yield Static("", id="status") yield Static("", id="status")
yield RichLog(id="log", wrap=True, highlight=True, markup=True) 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() yield Footer()
def on_mount(self) -> None: def on_mount(self) -> None:
@@ -263,6 +207,10 @@ class GenericAgentTUI(App[None]):
self._system("Welcome to GenericAgent TUI. Type /help for commands.") self._system("Welcome to GenericAgent TUI. Type /help for commands.")
self.query_one("#prompt", Input).focus() 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 @property
def current(self) -> AgentSession: def current(self) -> AgentSession:
if self.current_id is None: if self.current_id is None:
@@ -340,6 +288,8 @@ class GenericAgentTUI(App[None]):
"sessions": self._cmd_sessions, "sessions": self._cmd_sessions,
"stop": self._cmd_stop, "stop": self._cmd_stop,
"llm": self._cmd_llm, "llm": self._cmd_llm,
"branch": self._cmd_branch,
"rewind": self._cmd_rewind,
"quit": lambda _args: self.exit(), "quit": lambda _args: self.exit(),
"exit": lambda _args: self.exit(), "exit": lambda _args: self.exit(),
} }
@@ -412,13 +362,18 @@ class GenericAgentTUI(App[None]):
break break
else: else:
session.messages.append(ChatMessage("assistant", text, task_id=task_id, done=done)) 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: def _cmd_help(self, args: list[str]) -> None:
self._system( self._system(
"Commands:\n" "Commands:\n"
"/help - show this help\n" "/help - show this help\n"
"/new [name] - create and switch to a new agent session\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" "/switch <id|name> - switch active session\n"
"/sessions - list sessions\n" "/sessions - list sessions\n"
"/status - show current/all status\n" "/status - show current/all status\n"
@@ -434,6 +389,85 @@ class GenericAgentTUI(App[None]):
session = self.add_session(name) session = self.add_session(name)
self._system(f"Created session #{session.agent_id} {session.name!r}. Shared temp/memory are not isolated.") 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: def _cmd_switch(self, args: list[str]) -> None:
if not args: if not args:
self._system("Usage: /switch <id|name>") self._system("Usage: /switch <id|name>")
@@ -568,9 +602,8 @@ class GenericAgentTUI(App[None]):
model = session.agent.get_llm_name(model=True) model = session.agent.get_llm_name(model=True)
except Exception: except Exception:
model = "unknown" model = "unknown"
mode = "demo" if self.demo else "real"
status.update( 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)." "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: def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Textual TUI for GenericAgent") 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 return parser
def main(argv: Optional[list[str]] = None) -> int: def main(argv: Optional[list[str]] = None) -> int:
args = build_arg_parser().parse_args(argv) args = build_arg_parser().parse_args(argv)
app = GenericAgentTUI(demo=args.demo) app = GenericAgentTUI()
app.run() app.run()
return 0 return 0
+13 -2
View File
@@ -11,7 +11,8 @@
## 限制(isTrusted) ## 限制(isTrusted)
- JS事件`isTrusted=false`,敏感操作(如文件上传/部分按钮)可能被拦截;这类场景首选**CDP桥** - JS事件`isTrusted=false`,敏感操作(如文件上传/部分按钮)可能被拦截;这类场景首选**CDP桥**
- ⚠JS点击按钮打不开新tab→可能是浏览器弹窗拦截,换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` - 需转物理坐标时:`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查 - ⚠tabId:CDP默认sender.tab.id(当前注入页),跨tab需显式tabId或先batch内tabs查
- ⭐跨tab无需前台:指定tabId即可操作后台标签页 - ⭐跨tab无需前台:指定tabId即可操作后台标签页
## CDP点击完整生命周期(未验证,BBS#23) ## CDP点击完整生命周期(✅已验证)
- 通用点击需**三事件序列**:mouseMoved → mousePressed → mouseReleased(间隔50-100ms) - 通用点击需**三事件序列**:mouseMoved → mousePressed → mouseReleased(间隔50-100ms)
- 省略mouseMoved会导致MUI Tooltip/Ant Design Dropdown等hover依赖组件失效 - 省略mouseMoved会导致MUI Tooltip/Ant Design Dropdown等hover依赖组件失效
- ⚠autofill释放是特例,只需mousePressed即可(见下方autofill章节) - ⚠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时): - 坐标修正(页面有transform:scale/zoom时):
```js ```js
var scale = window.visualViewport ? window.visualViewport.scale : 1; var scale = window.visualViewport ? window.visualViewport.scale : 1;
+15 -12
View File
@@ -83,21 +83,24 @@ function createEnhancedDOMCopy() {
const hasVisualBg = bgAlpha > 0.1 || style.backgroundImage !== 'none' || (style.backdropFilter && style.backdropFilter !== 'none') || style.boxShadow !== 'none'; const hasVisualBg = bgAlpha > 0.1 || style.backgroundImage !== 'none' || (style.backdropFilter && style.backdropFilter !== 'none') || style.boxShadow !== 'none';
if (!hasVisualBg && childrenInfos.length > 0) { if (!hasVisualBg && childrenInfos.length > 0) {
let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity; // Skip fixed/absolute children when computing parent's merged rect (they're out of flow)
for (const cInfo of childrenInfos) { const flowChildren = childrenInfos.filter(cInfo => cInfo.style && cInfo.style.position !== 'fixed' && cInfo.style.position !== 'absolute');
minL = Math.min(minL, cInfo.rect.left); if (flowChildren.length > 0) {
minT = Math.min(minT, cInfo.rect.top); let minL = Infinity, minT = Infinity, maxR = -Infinity, maxB = -Infinity;
maxR = Math.max(maxR, cInfo.rect.right); for (const cInfo of flowChildren) {
maxB = Math.max(maxB, cInfo.rect.bottom); 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 (sourceNode.nodeType === 1 && sourceNode.tagName === 'DIV') {
if (!hasValidChildren && !sourceNode.textContent.trim()) return null; if (!hasValidChildren && !sourceNode.textContent.trim()) return null;