feat: add /btw side-question subagent (#317)
Mirrors Claude Code's /btw: ask the agent a quick question without interrupting the in-flight main task. - frontends/btw_cmd.py: snapshot backend.history under lock + deepcopy → background-thread backend.raw_ask single-turn answer; never writes backend.history; never touches task_queue. Reuses backend.raw_ask + make_messages, no new LLM instance. - frontends/chatapp_common.py: register /btw in HELP_COMMANDS; handle /btw in AgentChatMixin (sync via asyncio.to_thread); install() at module bottom so the slash hook is wired for any frontend that imports chatapp_common. - frontends/stapp.py: persist display_queue + partial_response in st.session_state so the streaming bubble survives Streamlit reruns; new render_main_stream() helper covers both new tasks and resume; /btw branch uses plain st.rerun() (preserves dq) so the main task's remaining output renders in a follow-up bubble; non-/btw prompts explicitly call agent.abort() to keep the original "submit cancels" UX. Strip <summary> meta tags from text segments and drop dangling "LLM Running (Turn N) ..." markers in resumed partials to clean up the rendered chat.
This commit is contained in:
1 parent
dd9a9c5b04
commit
a2e1686218
3 files changed
+223
-32
No files matched your search
+76
-32
@@ -16,6 +16,7 @@ import time, json, re, threading, queue
|
||||
from agentmain import GeneraticAgent
|
||||
import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
|
||||
from continue_cmd import handle_frontend_command, reset_conversation, list_sessions, extract_ui_messages
|
||||
from btw_cmd import handle_frontend_command as btw_handle_frontend
|
||||
|
||||
st.set_page_config(page_title="Cowork", layout="wide")
|
||||
|
||||
@@ -139,6 +140,8 @@ def fold_turns(text):
|
||||
segments.append({'type': 'fold', 'title': title, 'content': content})
|
||||
else: segments.append({'type': 'text', 'content': marker + content})
|
||||
return segments
|
||||
_SUMMARY_TAG_RE = re.compile(r'<summary>.*?</summary>\s*', re.DOTALL)
|
||||
|
||||
def render_segments(segments, suffix=''):
|
||||
# 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。
|
||||
# heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁;
|
||||
@@ -147,22 +150,63 @@ def render_segments(segments, suffix=''):
|
||||
if seg['type'] == 'fold':
|
||||
with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])
|
||||
else:
|
||||
st.markdown(seg['content'] + suffix)
|
||||
# Strip <summary> meta tags from text segments — folded turns already
|
||||
# promote them to expander titles; for the first/last segments
|
||||
# they'd otherwise leak into the chat as raw text (esp. after /continue
|
||||
# restores a multi-turn body).
|
||||
st.markdown(_SUMMARY_TAG_RE.sub('', seg['content']) + suffix)
|
||||
|
||||
def agent_backend_stream(prompt):
|
||||
display_queue = agent.put_task(prompt, source="user")
|
||||
response = ''
|
||||
try:
|
||||
while True:
|
||||
try: item = display_queue.get(timeout=1)
|
||||
except queue.Empty:
|
||||
yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
|
||||
continue
|
||||
if 'next' in item:
|
||||
response = item['next']; yield response
|
||||
if 'done' in item:
|
||||
yield item['done']; break
|
||||
finally: agent.abort()
|
||||
def agent_backend_stream(prompt=None):
|
||||
"""Drain main task display_queue.
|
||||
- prompt given: start a fresh task; new dq is kept in session_state.
|
||||
- prompt is None: resume a dq left in session_state by a prior run (e.g. after /btw).
|
||||
Per-chunk progress is mirrored to session_state.partial_response so the rendered
|
||||
bubble survives reruns. No implicit agent.abort() — explicit stop is on the Stop button."""
|
||||
if prompt is not None:
|
||||
st.session_state.display_queue = agent.put_task(prompt, source="user")
|
||||
st.session_state.partial_response = ''
|
||||
dq = st.session_state.get('display_queue')
|
||||
if dq is None: return
|
||||
# Drop a dangling 'LLM Running (Turn N) ...' marker if the captured partial
|
||||
# ended right at a turn boundary with no content yet — otherwise the resume
|
||||
# bubble flashes as a marker-only gray line. The marker reappears with
|
||||
# content on the next chunk (raw_resp is cumulative).
|
||||
response = re.sub(r'\**LLM Running \(Turn \d+\) \.\.\.\**\s*$',
|
||||
'', st.session_state.get('partial_response', '')).rstrip()
|
||||
while True:
|
||||
try: item = dq.get(timeout=1)
|
||||
except queue.Empty:
|
||||
yield response # heartbeat: let outer st.markdown() run → Streamlit checks StopException
|
||||
continue
|
||||
if 'next' in item:
|
||||
response = item['next']
|
||||
st.session_state.partial_response = response
|
||||
yield response
|
||||
if 'done' in item:
|
||||
st.session_state.display_queue = None
|
||||
st.session_state.partial_response = ''
|
||||
yield item['done']; break
|
||||
|
||||
|
||||
def render_main_stream(prompt=None):
|
||||
"""Render the assistant bubble for the main task (new or resumed). Saves final to messages."""
|
||||
with st.chat_message("assistant"):
|
||||
frozen = 0; live = st.empty(); response = ''
|
||||
CURSOR = ' ▌'
|
||||
for response in agent_backend_stream(prompt):
|
||||
segs = fold_turns(response)
|
||||
n_done = max(0, len(segs) - 1)
|
||||
while frozen < n_done:
|
||||
with live.container(): render_segments([segs[frozen]])
|
||||
live = st.empty(); frozen += 1
|
||||
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
|
||||
segs = fold_turns(response)
|
||||
for i in range(frozen, len(segs)):
|
||||
with live.container(): render_segments([segs[i]])
|
||||
if i < len(segs) - 1: live = st.empty()
|
||||
if response:
|
||||
st.session_state.messages.append({"role": "assistant", "content": response})
|
||||
st.session_state.last_reply_time = int(time.time())
|
||||
|
||||
if "messages" not in st.session_state: st.session_state.messages = []
|
||||
for msg in st.session_state.messages:
|
||||
@@ -234,26 +278,26 @@ if prompt := st.chat_input("any task?"):
|
||||
st.session_state.messages = list(st.session_state.messages) + \
|
||||
[{"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) + [
|
||||
{"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
|
||||
# Regular prompt: cancel any in-flight task to match original "submit cancels" UX.
|
||||
# (/btw branch above is the only path that intentionally lets the prior task keep streaming.)
|
||||
if st.session_state.get('display_queue') is not None:
|
||||
agent.abort()
|
||||
st.session_state.display_queue = None
|
||||
st.session_state.partial_response = ''
|
||||
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)
|
||||
|
||||
with st.chat_message("assistant"):
|
||||
frozen = 0; live = st.empty(); response = ''
|
||||
CURSOR = ' ▌'
|
||||
for response in agent_backend_stream(prompt):
|
||||
segs = fold_turns(response)
|
||||
n_done = max(0, len(segs) - 1)
|
||||
while frozen < n_done:
|
||||
with live.container(): render_segments([segs[frozen]])
|
||||
live = st.empty(); frozen += 1
|
||||
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
|
||||
segs = fold_turns(response)
|
||||
for i in range(frozen, len(segs)):
|
||||
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.last_reply_time = int(time.time())
|
||||
render_main_stream(prompt)
|
||||
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.
|
||||
render_main_stream()
|
||||
|
||||
if st.session_state.autonomous_enabled:
|
||||
st.markdown(f"""<div id="last-reply-time" style="display:none">{st.session_state.get('last_reply_time', int(time.time()))}</div>""", unsafe_allow_html=True)
|
||||
Reference in new issue
Block a user