diff --git a/agentmain.py b/agentmain.py
index 6f04ccef..2debf2ec 100644
--- a/agentmain.py
+++ b/agentmain.py
@@ -47,9 +47,12 @@ class GenericAgent:
os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True)
self.lock = threading.Lock()
self.task_dir = None
- self.history = []; self.handler = None;
+ self.history = []; self.handler = None; self.all_outputs = []
self.task_queue = queue.Queue()
- self.is_running = False; self.stop_sig = False; self.llm_no = 0;
+ self.is_running = False; self.stop_sig = False; self.llm_no = 0;
+ # Output queue of the task currently executing (None when idle). Lets a UI that
+ # lost its own handle (page refresh, second client) re-attach to the live task.
+ self._current_queue = None
self.inc_out = False; self.verbose = True
self.peer_hint = True
self.force_non_stream = False
@@ -144,11 +147,13 @@ class GenericAgent:
raw_query = self._handle_slash_cmd(raw_query, display_queue)
if raw_query is None:
self.task_queue.task_done(); continue
- self.is_running = True
+ self.is_running = True; self._current_queue = display_queue
if len(raw_query) > 2000:
task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md')
with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query)
raw_query = f'Long user prompt saved to {task_file}. Read and execute.'
+ self.all_outputs.append({"input": raw_query, "outputs": []})
+ if len(self.all_outputs) > 10000: self.all_outputs = self.all_outputs[-5000:]
rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200)
self.history.append(f"[USER]: {rquery}")
sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '')
@@ -168,7 +173,7 @@ class GenericAgent:
gen = agent_runner_loop(self.llmclient, sys_prompt, raw_query, handler, TOOLS_SCHEMA,
max_turns=180, verbose=self.verbose, yield_info=True)
try:
- full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = []
+ full_resp = ""; last_pos = 0; curr_turn = 0; turn_resps = self.all_outputs[-1]["outputs"]
for chunk in gen:
if consume_file(self.task_dir, '_stop'): self.abort()
if self.stop_sig: break
@@ -189,7 +194,7 @@ class GenericAgent:
display_queue.put({'done': full_resp + f'\n```\n{format_error(e)}\n```', 'source': source, 'turn': curr_turn, 'outputs': turn_resps.copy()})
finally:
if self.stop_sig: print('User aborted the task.')
- self.is_running = self.stop_sig = False
+ self.is_running = self.stop_sig = False # keep _current_queue: its final 'done' may still be unclaimed (refreshed UI salvages it); next task overwrites it
self.task_queue.task_done()
if self.handler is not None: self.handler.code_stop_signal.append(1)
diff --git a/frontends/conductor.html b/frontends/conductor.html
index b3a19520..5e1621a1 100644
--- a/frontends/conductor.html
+++ b/frontends/conductor.html
@@ -61,9 +61,9 @@
overflow: hidden;
}
- .left-col { min-height: 0; display: flex; flex-direction: column; gap: 18px; }
+ .left-col { min-height: 0; min-width: 0; display: flex; flex-direction: column; gap: 18px; }
.left-col > .pane.left { flex: 1; min-height: 0; }
- .left, .right { display: flex; flex-direction: column; min-height: 0; }
+ .left, .right { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
.right { background: transparent; border: 0; box-shadow: none; backdrop-filter: none; gap: 18px; }
.head {
@@ -200,10 +200,15 @@
.btn-no { background: linear-gradient(135deg, #f87171, #ef4444); box-shadow: 0 8px 20px rgba(239,68,68,.22); }
/* ---------- 聊天框 ---------- */
- .chat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
- .messages { flex: 1; overflow: auto; padding: 20px; display: flex; flex-direction: column; gap: 13px; }
+ .chat { flex: 1; min-height: 0; min-width: 0; display: flex; flex-direction: column; }
+ .messages { flex: 1; min-width: 0; overflow: auto; padding: 20px; display: flex; flex-direction: column; gap: 13px; }
.msg { max-width: 80%; padding: 13px 15px; border-radius: 18px; line-height: 1.45; font-size: 14px;
- word-wrap: break-word; box-shadow: 0 8px 22px rgba(30,45,80,.055); }
+ overflow-wrap: anywhere; box-shadow: 0 8px 22px rgba(30,45,80,.055); }
+ .msg :is(p, ul, ol, pre, table, img) { max-width: 100%; }
+ .msg pre { white-space: pre-wrap; overflow-wrap: anywhere; }
+ .msg code { overflow-wrap: anywhere; }
+ .msg table { display: block; overflow-x: auto; }
+ .msg img { height: auto; }
.msg p { margin: 0 0 .3em 0; } .msg p:last-child { margin-bottom: 0; }
.msg.me { align-self: flex-end; color: white;
background: linear-gradient(135deg, #3b82f6, #7c3aed); border: 1px solid rgba(59,130,246,.20);
diff --git a/frontends/conductor.py b/frontends/conductor.py
index 627e6a6a..9202ce7d 100644
--- a/frontends/conductor.py
+++ b/frontends/conductor.py
@@ -234,9 +234,12 @@ def add_chat(msg: str, role: str = "conductor", files: list = None, images: list
files = list(files or [])
known = {x.get("path") for x in files}
for ref in re.findall(r"`([^`\n]+)`", msg):
- try: path = file_path(ref)
- except ValueError: continue
- if os.path.isfile(path) and path not in known: files.append({"ref": ref, "path": path, "name": os.path.basename(path)})
+ for candidate in ([ref] if os.path.isabs(ref) else [os.path.join(FILE_HOME, ref), ref]):
+ try: path = file_path(candidate)
+ except ValueError: continue
+ if os.path.isfile(path):
+ if path not in known: files.append({"ref": ref, "path": path, "name": os.path.basename(path)})
+ break
item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user", "files": files, "images": images or []}
chat_messages.append(item)
if len(chat_messages) > 200: del chat_messages[:-200]
diff --git a/frontends/hub.html b/frontends/hub.html
new file mode 100644
index 00000000..bc9298d2
--- /dev/null
+++ b/frontends/hub.html
@@ -0,0 +1,269 @@
+
+
+
+
+GA Hub
+
+
+
+
+
+
+
+
+
+
diff --git a/frontends/hub.py b/frontends/hub.py
new file mode 100644
index 00000000..d50b926d
--- /dev/null
+++ b/frontends/hub.py
@@ -0,0 +1,150 @@
+"""GA Hub: `import hub` -> client (silent if no server); `python hub.py` -> WS server.
+ hub.connect(agent, 'stapp') # or override any hook: connect(a, n, put_task=, get_outputs=, abort=)
+ default put -> 'busy' if agent.is_running, else parks {'text','q'} in agent._hub_inbox for the UI
+HTTP (errors = {'error','code'} + status: offline/gone 404, busy 409, timeout 504, nosupport 501, badop 400):
+ GET peers -> [{name,title,n_msgs,sig}] | {name}/messages?detail=1&sig= -> {title,tasks:[{i,input,steps:
+ [{j,title,n}]}],sig} or {same:1,sig} | {name}/seg/{i}/{j}?off=N -> {content,off,n} (step bodies, tailable)
+ POST {name}/put {"text":..} -> {ok:1} | {name}/abort -> {ok:1}
+"""
+import os, re, sys, json, time, asyncio, threading
+PORT = int(os.environ.get('GA_HUB_PORT', 19736))
+URL = os.environ.get('GA_HUB', f'ws://127.0.0.1:{PORT}/ws')
+TITLE_MIN = 12 # a too short last input gets the previous one prepended
+NOISE = re.compile(r'\**LLM Running \(Turn \d+\) \.\.\.\**|`{3,}.*?`{3,}|.*?', re.DOTALL)
+
+class HubClient:
+ def __init__(self, name, put_task, get_outputs, abort=None):
+ self.name, self.put_task, self.get_outputs, self.abort = name, put_task, get_outputs, abort
+ self._tc = {} # (i,j) -> (fingerprint, title): a finished step is never re-scanned
+ def start(self):
+ threading.Thread(target=lambda: asyncio.run(self._loop()), daemon=True).start(); return self
+ async def _loop(self):
+ try: import websockets
+ except ImportError: return
+ while True:
+ try:
+ async with websockets.connect(URL, open_timeout=3, max_size=None) as ws:
+ await ws.send(json.dumps({'op': 'hello', 'name': self.name, 'pid': os.getpid()}))
+ async for raw in ws: await self._on_cmd(ws, json.loads(raw))
+ except Exception: pass # silent: the hub is optional, it must not disturb the host
+ await asyncio.sleep(5)
+ def _stitle(self, i, j, s):
+ s, e = s or '', self._tc.get((i, j))
+ fp = (len(s), s[:24] + s[-24:]) # memoised on length+edges: guards index reuse after /clear
+ if e and e[0] == fp: return e[1]
+ body = NOISE.sub('', s) # title = if present, else first meaningful line
+ m = re.search(r'\s*(.*?)\s*', body, re.DOTALL)
+ t = (m.group(1) if m else body.strip()).strip().split('\n')[0]; t = t[:50] + '...' if len(t) > 50 else t or f'step {j + 1}'
+ self._tc[(i, j)] = (fp, t); return t
+ def _build(self, c):
+ """Skeleton only (no bodies), off-loop: regex/serialisation must not stall the WS reader."""
+ tasks = list(self.get_outputs() or []); outs = [list(t.get('outputs') or []) for t in tasks] # copy: host mutates
+ sig = '.'.join(str(len(o)) for o in outs) + ':' + str(len(outs[-1][-1] or '') if outs and outs[-1] else 0)
+ if c.get('sig') == sig: return {'same': 1, 'sig': sig} # nothing moved -> no payload at all
+ ins = [(t.get('input') or '').strip() for t in tasks]
+ ins = [u for u in ins if u and not u.startswith('/')] or [u for u in ins if u] # /clear is plumbing
+ title = ins[-1].split('\n')[0] if ins else '(empty)'
+ if len(ins) > 1 and len(title) < TITLE_MIN: title = ins[-2].split('\n')[0] + ' <- ' + title
+ det, rows = int(c.get('detail', 1)), []
+ for i, (t, o) in enumerate(zip(tasks, outs)):
+ steps = [{'j': j, 'title': self._stitle(i, j, s), 'n': len(s or '')} for j, s in enumerate(o)]
+ rows.append({'i': i, 'input': t.get('input', ''), **({'steps': steps} if det else {'ns': len(o)})})
+ return {'title': title[:80], 'tasks': rows, 'sig': sig}
+ def _seg(self, c):
+ tasks, off = list(self.get_outputs() or []), max(0, int(c.get('off') or 0))
+ i, j = int(c.get('i', -1)), int(c.get('j', -1))
+ o = (tasks[i].get('outputs') or []) if -len(tasks) <= i < len(tasks) else None
+ if o is None or not (-len(o) <= j < len(o)):
+ return {'error': f'segment {i}/{j} gone (trimmed or cleared)', 'code': 'gone'}
+ s = o[j] or ''; return {'content': s[off:], 'off': off, 'n': len(s)} # off lets a UI tail a growing step
+ async def _on_cmd(self, ws, c):
+ op = c.get('op'); data = {'error': f'unknown op {op}', 'code': 'badop'}
+ if op in ('get', 'seg'): data = await asyncio.to_thread(self._build if op == 'get' else self._seg, c)
+ elif op == 'put_task': data = (c.get('text') and await asyncio.to_thread(self.put_task, c['text'])) or {'ok': 1}
+ elif op == 'abort': data = (await asyncio.to_thread(self.abort) or {'ok': 1}) if self.abort else {'error': 'no abort hook', 'code': 'nosupport'}
+ await ws.send(json.dumps({'op': 'r', 'id': c.get('id'), 'name': self.name, 'data': data}, default=str))
+
+def connect(agent, name=None, put_task=None, get_outputs=None, abort=None, fold=None):
+ """One line to wire a GA host: `hub.connect(agent, 'stapp')`; any hook can still be overridden.
+ Default put refuses while the agent is busy (a remote must not cut in line), else it parks
+ (text, queue) in agent._hub_inbox so a UI can pop it and echo the task as its own bubble.
+ Never raises: a broken hub must not break its host."""
+ def _put(text):
+ if getattr(agent, 'is_running', False): return {'error': f'peer {name} is busy', 'code': 'busy'}
+ agent._hub_inbox.append({'text': text, 'q': agent.put_task(text, source='hub')})
+ try:
+ if not hasattr(agent, '_hub_inbox'): agent._hub_inbox = []
+ return HubClient(name or getattr(agent, 'name', 'agent'), put_task or _put,
+ get_outputs or (lambda: agent.all_outputs), abort or agent.abort).start()
+ except Exception: return None
+
+# ------------------------------- server (python hub.py) -------------------------------
+if __name__ == '__main__':
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, Response
+ from fastapi.responses import FileResponse, JSONResponse; import uvicorn
+ HERE, OK_ORIGIN = os.path.dirname(os.path.abspath(__file__)), ('', f'http://127.0.0.1:{PORT}', f'http://localhost:{PORT}')
+ STATUS = {'offline': 404, 'gone': 404, 'timeout': 504, 'nosupport': 501, 'badop': 400, 'busy': 409}
+ app, peers, waits, seq, pcache, pdead = FastAPI(), {}, {}, [0], {}, {} # ws / futures / last row / next probe
+ @app.middleware('http')
+ async def same_origin(req: Request, call_next): # browsers send Origin, native clients don't
+ return await call_next(req) if (req.headers.get('origin') or '') in OK_ORIGIN else Response(status_code=403)
+ async def ask(name, msg, timeout=15):
+ if name not in peers: return {'error': f'peer {name} offline', 'code': 'offline'}
+ seq[0] += 1; rid = seq[0]
+ waits[rid] = fut = asyncio.get_running_loop().create_future()
+ try:
+ await peers[name].send_text(json.dumps(dict(msg, id=rid)))
+ return await asyncio.wait_for(fut, timeout) # the WS reader resolves it
+ except asyncio.TimeoutError: return {'error': f'peer busy (>{timeout}s)', 'code': 'timeout'}
+ except Exception as e: return {'error': str(e) or type(e).__name__, 'code': 'offline'}
+ finally: waits.pop(rid, None)
+ def out(d): # a peer-level error becomes a real HTTP status, not a 200 with a body
+ c = d.get('code') if isinstance(d, dict) else None; return JSONResponse(d, STATUS.get(c, 400)) if c else d
+ @app.websocket('/ws')
+ async def endpoint(ws: WebSocket):
+ if (ws.headers.get('origin') or '') not in OK_ORIGIN: return await ws.close(code=1008)
+ await ws.accept(); name = None
+ try:
+ while True:
+ m = json.loads(await ws.receive_text())
+ if m.get('op') == 'hello':
+ name = re.sub(r'[^\w.-]', '_', f"{m.get('name')}-{m.get('pid')}") # it lands in a URL path
+ peers[name] = ws; print(f'[+] {name} ({len(peers)} online)')
+ f = waits.get(m.get('id')) if m.get('op') == 'r' else None
+ if f and not f.done(): f.set_result(m.get('data'))
+ except WebSocketDisconnect: pass
+ finally:
+ if name: peers.pop(name, None); print(f'[-] {name} ({len(peers)} online)')
+ @app.get('/api/peers')
+ async def api_peers(): # one cheap round-trip per peer: counts only, and only if its sig moved
+ now = time.time()
+ names = [n for n in peers if now >= pdead.get(n, 0)]
+ rs = await asyncio.gather(*[ask(n, {'op': 'get', 'detail': 0,
+ 'sig': (pcache.get(n) or {}).get('sig')}, 3) for n in names])
+ for n, r in zip(names, rs): pdead[n] = now + 15 if (r or {}).get('error') else 0
+ rs, rows = dict(zip(names, rs)), []
+ for n in peers:
+ r = rs.get(n)
+ if r is None or r.get('error') or (r.get('same') and n in pcache): # busy/skipped/idle -> last row
+ rows.append(pcache.get(n) or {'name': n, 'title': '?', 'n_msgs': 0, 'sig': None}); continue
+ msgs = sum(1 for t in r.get('tasks', []) if not (t.get('input') or '').lstrip().startswith('/'))
+ rows.append({'name': n, 'title': r.get('title', '?'), 'n_msgs': msgs, 'sig': r.get('sig')})
+ if r.get('sig'): pcache[n] = rows[-1]
+ for n in [x for x in pcache if x not in peers]: pcache.pop(n, None)
+ return rows
+ @app.get('/api/{name}/messages')
+ async def api_messages(name: str, detail: int = 1, sig: str = None): return out(await ask(name, {'op': 'get', 'detail': detail, 'sig': sig}))
+ @app.get('/api/{name}/seg/{i}/{j}')
+ async def api_seg(name: str, i: int, j: int, off: int = 0): return out(await ask(name, {'op': 'seg', 'i': i, 'j': j, 'off': off}))
+ @app.post('/api/{name}/put')
+ async def api_put(name: str, body: dict): return out(await ask(name, {'op': 'put_task', 'text': body.get('text', '')}))
+ @app.post('/api/{name}/abort')
+ async def api_abort(name: str): return out(await ask(name, {'op': 'abort'}))
+ @app.get('/')
+ async def index(): return FileResponse(os.path.join(HERE, 'hub.html'))
+ @app.get('/vendor/{f}')
+ async def vendor(f: str):
+ p = os.path.join(HERE, 'desktop', 'static', 'vendor', os.path.basename(f))
+ return FileResponse(p) if os.path.exists(p) else Response(status_code=404)
+ try: uvicorn.run(app, host='127.0.0.1', port=PORT, log_level='warning')
+ except OSError: sys.exit(f'hub already running on {PORT}')
diff --git a/frontends/stapp.py b/frontends/stapp.py
index e32c0b75..4d2357e2 100644
--- a/frontends/stapp.py
+++ b/frontends/stapp.py
@@ -49,6 +49,7 @@ I18N = {
'auto_on_cap': '🟢 已允许:约1分钟后启动,之后空闲30分钟再触发',
'auto_off_cap': '🔴 自主行动已停止',
'auto_prompt': '[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,请阅读自动化sop,执行自动任务。',
+ 'detached_running': '⏳ 后台任务运行中…(本页已刷新,实时流不再接入;完成后自动刷新)',
'get_token': '🔑 获取 Token',
'get_token_toast': '已打开浏览器',
'need_mykey': '⚠️ 请配置 mykey.py',
@@ -66,6 +67,7 @@ I18N = {
'auto_on_cap': '🟢 On: first run ~1min, then every 30min idle',
'auto_off_cap': '🔴 Auto-action disabled',
'auto_prompt': '[AUTO]🤖 User has been idle for over 30 minutes. As an autonomous agent, read the automation SOP and execute automatic tasks.',
+ 'detached_running': '⏳ A task is running in the background… (this page was refreshed, live stream not attached; will refresh when done)',
'get_token': '🔑 Get Token',
'get_token_toast': 'Opened in browser',
'need_mykey': '⚠️ Please set mykey.py',
@@ -83,6 +85,8 @@ def init():
return agent
agent = init()
+# NOTE: never abort merely because a new session appeared (F5 / 2nd tab / external probe).
+# Aborting is an *intent*, triggered only by a new prompt or the Stop button.
_sp = getattr(agentmain, "start_subscription_portal", None)
@st.fragment(run_every=timedelta(seconds=2))
@@ -300,8 +304,8 @@ def _poll_main_task(max_items=256):
return done
-def _render_stat_badge(is_running, target=None):
- """Render task usage/time into one stable element, without clearing its container."""
+def _render_stat_badge(is_running):
+ """Render task usage/time in the current app or fragment context."""
if 'task_start_ts' not in st.session_state or not hasattr(llmcore, 'STATS'):
return
end_ts = time.time() if is_running else st.session_state.get('task_end_ts', time.time())
@@ -312,13 +316,12 @@ def _render_stat_badge(is_running, target=None):
f"{short(stats['ctx'])} chars·{stats['msgs']}msgs │ "
f"in {short(stats.get('inp', 0))} toks·cached{short(stats.get('cached', 0))}·out{short(stats.get('out', 0))} │ "
if 'ctx' in stats else '')
- renderer = target.markdown if target is not None else st.markdown
- renderer(f'{usage}{secs // 60}:{secs % 60:02d}
',
- unsafe_allow_html=True)
+ st.markdown(f'{usage}{secs // 60}:{secs % 60:02d}
',
+ unsafe_allow_html=True)
@st.fragment(run_every=timedelta(seconds=1))
-def render_main_stream(frozen_host, live_slot, stats_slot, render_state):
+def render_main_stream(frozen_host, live_slot, render_state):
"""Append completed turns outside the fragment; redraw only the active turn."""
done = _poll_main_task()
if done is not None:
@@ -344,21 +347,24 @@ def render_main_stream(frozen_host, live_slot, stats_slot, render_state):
# These external slots keep stable delta paths; only their contents change.
with live_slot.container():
render_segments([segments[-1]], suffix=" ▌")
- _render_stat_badge(is_running=True, target=stats_slot)
+ _render_stat_badge(is_running=True)
-def mount_main_stream(stats_slot):
- """Create persistent targets captured by the fragment until the next full rerun."""
+def mount_main_stream():
+ """Create persistent response targets captured by the fragment until the next full rerun."""
with st.chat_message("assistant"):
frozen_host = st.container()
live_slot = st.empty()
- render_main_stream(frozen_host, live_slot, stats_slot, {'frozen': 0})
+ render_main_stream(frozen_host, live_slot, {'frozen': 0})
if not hasattr(agent, "_ui_messages"): agent._ui_messages = st.session_state.get("messages", [])
if "messages" not in st.session_state: st.session_state.messages = agent._ui_messages
+if not hasattr(agent, "_hub"):
+ import hub
+ agent._hub = hub.connect(agent, 'stapp') # busy-rejects; accepted tasks land in agent._hub_inbox
# Lazy history: long sessions (esp. after loop) render thousands of elements on every
# full-app rerun → seconds of gray/RUNNING. Only render the tail unless expanded.
-_HIST_TAIL = 6
+_HIST_TAIL = 10
_msgs = st.session_state.messages
if len(_msgs) > _HIST_TAIL:
if not st.session_state.get("show_full_history"):
@@ -398,10 +404,6 @@ _js_ime_fix = ("" if os.name == 'nt' else
"f();new MutationObserver(f).observe(d.body,{childList:1,subtree:1})}()")
_embed_html(f'', height=0)
-# Fixed target shared by full-app rendering and the one-second stream fragment.
-# The badge is position:fixed, so this placeholder does not affect its visual position.
-stats_slot = st.empty()
-
_injected = st.session_state.pop('_inject_prompt', None)
prompt = st.chat_input("any task?") or _injected
if prompt:
@@ -415,7 +417,7 @@ if prompt:
st.session_state.last_reply_time = int(time.time())
st.rerun()
if cmd == "/new":
- st.session_state.messages = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}]
+ st.session_state.messages[:] = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}]
_reset_and_rerun()
if cmd.startswith("/continue"):
m = re.match(r'/continue\s+(\d+)\s*$', cmd.strip())
@@ -426,15 +428,15 @@ if prompt:
result = handle_frontend_command(agent, cmd)
history = extract_ui_messages(target) if target and result.startswith('✅') else None
tail = [{"role": "assistant", "content": result, "time": ts}]
- if history: st.session_state.messages = history + tail
- else: st.session_state.messages = list(st.session_state.messages)+[{"role": "user", "content": cmd, "time": ts}]+tail
+ if history: st.session_state.messages[:] = history + tail
+ else: st.session_state.messages.extend([{"role": "user", "content": cmd, "time": ts}] + tail)
_reset_and_rerun()
if cmd.startswith("/btw"):
answer = btw_handle_frontend(agent, cmd) # sync; bypasses put_task → main agent.run() untouched
- st.session_state.messages = list(st.session_state.messages) + [
+ st.session_state.messages.extend([
{"role": "user", "content": prompt, "time": ts},
{"role": "assistant", "content": answer, "time": ts},
- ]
+ ])
st.rerun() # preserve display_queue/partial_response so resume path drains the running main task
if cmd.startswith("/export"):
parts = cmd.split(maxsplit=1)
@@ -463,29 +465,73 @@ if prompt:
result = f"✅ 已导出:\n\n`{path}`"
except Exception as e:
result = f"❌ 导出失败: {e}"
- st.session_state.messages = list(st.session_state.messages) + [
+ st.session_state.messages.extend([
{"role": "user", "content": cmd, "time": ts},
{"role": "assistant", "content": result, "time": ts},
- ]
+ ])
_reset_and_rerun()
# Regular prompt starts a new main task. Explicitly detach any prior task first;
# sidebar-only reruns never pass through this branch, so they keep the queue.
- if st.session_state.get("display_queue") is not None:
+ if agent.is_running or st.session_state.get("display_queue") is not None:
_cancel_main_task()
st.session_state.messages.append({"role": "user", "content": prompt})
if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')
with st.chat_message("user"): st.markdown(prompt)
_start_main_task(prompt)
- mount_main_stream(stats_slot)
+ mount_main_stream()
elif st.session_state.get('display_queue') is not None:
# No new prompt but a task is mid-flight (typically a /btw rerun) — resume drain.
- mount_main_stream(stats_slot)
+ mount_main_stream()
+elif getattr(agent, '_hub_inbox', None):
+ # A task pushed in over the hub: adopt it as if typed here, so it gets a user bubble and a
+ # live stream instead of the anonymous "something is running" notice. pop() -> the first tab
+ # to rerun claims it; later tabs fall through to the detached view below.
+ _hm = agent._hub_inbox.pop(0)
+ st.session_state.messages.append({"role": "user", "content": _hm['text']})
+ with st.chat_message("user"): st.markdown(_hm['text'])
+ st.session_state.display_queue = _hm['q']
+ st.session_state.partial_response = ""
+ st.session_state.task_start_ts = time.time()
+ mount_main_stream()
+elif agent.is_running or (getattr(agent, '_current_queue', None) is not None
+ and not agent._current_queue.empty()):
+ # A task is running but this session owns no queue (page was refreshed / opened in a
+ # 2nd tab). The stream is per-session and intentionally gone; only show liveness so the
+ # page is never blank-and-silent, and refresh once the backend goes idle.
+ @st.fragment(run_every=timedelta(seconds=1))
+ def _watch_detached_task():
+ # Re-attach to the live task's queue only to salvage its final ``done`` so the
+ # answer still lands in history. The ``next`` stream is per-session and is
+ # deliberately dropped rather than resumed.
+ dq = getattr(agent, "_current_queue", None)
+ if dq is not None:
+ while True:
+ try: item = dq.get_nowait()
+ except Exception: break
+ if isinstance(item, dict) and "done" in item:
+ if item["done"]:
+ st.session_state.messages.append({"role": "assistant", "content": item["done"]})
+ st.rerun(scope="app"); return
+ if not agent.is_running:
+ st.rerun(scope="app"); return
+ st.chat_message("assistant").markdown(T("detached_running"))
+
+ _watch_detached_task()
+
+@st.fragment(run_every=timedelta(seconds=2))
+def _watch_hub_inbox():
+ # An idle page has no heartbeat, so a task arriving from the hub would sit unnoticed until the
+ # next click. Poll cheaply and hand it to the claim branch above via a full rerun.
+ if getattr(agent, '_hub_inbox', None) and st.session_state.get('display_queue') is None:
+ st.rerun(scope="app")
+
+_watch_hub_inbox()
# Usage/time label remains after completion. During a task render_main_stream refreshes it.
_has_task_stats = 'task_start_ts' in st.session_state
_is_running = st.session_state.get('display_queue') is not None
if _has_task_stats and not _is_running:
- _render_stat_badge(is_running=False, target=stats_slot)
+ _render_stat_badge(is_running=False)
if _has_task_stats or _is_running:
st.markdown(