feat: add continue/new support to chat frontends

This commit is contained in:
Xinyi Wang
2026-04-20 19:27:31 +08:00
parent db3a807141
commit d1a491ff3b
5 changed files with 177 additions and 15 deletions

View File

@@ -115,6 +115,14 @@ python frontends/qtapp.py # Qt-based desktop app
streamlit run frontends/stapp2.py # Alternative Streamlit UI streamlit run frontends/stapp2.py # Alternative Streamlit UI
``` ```
### Common Chat Commands
The default Streamlit desktop UI started by `python launch.pyw`, plus the QQ / Feishu / WeCom / DingTalk frontends, support these chat commands:
- `/new` - start a fresh conversation and clear the current context
- `/continue` - list recoverable conversation snapshots
- `/continue N` - restore the `N`th recoverable conversation
## 📊 Comparison with Similar Tools ## 📊 Comparison with Similar Tools
@@ -381,6 +389,14 @@ python frontends/qtapp.py # 基于 Qt 的桌面应用
streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI
``` ```
### 通用聊天命令
默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI以及 QQ / 飞书 / 企业微信 / 钉钉前端,都支持以下命令:
- `/new` - 开启新对话并清空当前上下文
- `/continue` - 列出可恢复会话快照
- `/continue N` - 恢复第 `N` 个可恢复会话
## 📊 与同类产品对比 ## 📊 与同类产品对比

View File

@@ -1,6 +1,6 @@
import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time import ast, asyncio, glob, json, os, queue as Q, re, socket, sys, time
HELP_TEXT = "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 清空当前上下文\n/restore - 恢复上次对话历史\n/llm [n] - 查看或切换模型" HELP_TEXT = "📖 命令列表:\n/help - 显示帮助\n/status - 查看状态\n/stop - 停止当前任务\n/new - 开启新对话并清空当前上下文\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/llm [n] - 查看或切换模型"
FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response." FILE_HINT = "If you need to show files to user, use [FILE:filepath] in your response."
TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")] TAG_PATS = [r"<" + t + r">.*?</" + t + r">" for t in ("thinking", "summary", "tool_use", "file_content")]
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -237,6 +237,8 @@ class AgentChatMixin:
async def handle_command(self, chat_id, cmd, **ctx): async def handle_command(self, chat_id, cmd, **ctx):
parts = (cmd or "").split() parts = (cmd or "").split()
op = (parts[0] if parts else "").lower() op = (parts[0] if parts else "").lower()
if op == "/help":
return await self.send_text(chat_id, HELP_TEXT, **ctx)
if op == "/stop": if op == "/stop":
state = self.user_tasks.get(chat_id) state = self.user_tasks.get(chat_id)
if state: if state:
@@ -268,10 +270,10 @@ class AgentChatMixin:
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx) return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
except Exception as e: except Exception as e:
return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx) return await self.send_text(chat_id, f"❌ 恢复失败: {e}", **ctx)
if op == "/continue":
return await self.send_text(chat_id, _handle_continue_frontend(self.agent, cmd), **ctx)
if op == "/new": if op == "/new":
self.agent.abort() return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx)
self.agent.history = []
return await self.send_text(chat_id, "🆕 已清空当前共享上下文", **ctx)
return await self.send_text(chat_id, HELP_TEXT, **ctx) return await self.send_text(chat_id, HELP_TEXT, **ctx)
async def run_agent(self, chat_id, text, **ctx): async def run_agent(self, chat_id, text, **ctx):
@@ -304,5 +306,5 @@ class AgentChatMixin:
from agentmain import GeneraticAgent as _GA from agentmain import GeneraticAgent as _GA
from continue_cmd import install as _install_continue from continue_cmd import handle_frontend_command as _handle_continue_frontend, install as _install_continue, reset_conversation as _reset_conversation
_install_continue(_GA) _install_continue(_GA)

View File

@@ -2,10 +2,12 @@
Pure functions + one `install(cls)` monkey-patch entry. No side effects at import. Pure functions + one `install(cls)` monkey-patch entry. No side effects at import.
""" """
import ast, glob, json, os, re, time import ast, glob, json, os, re, time
_LOG_GLOB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'temp', 'model_responses', 'model_responses_*.txt') 'temp', 'model_responses')
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)', _BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)',
re.DOTALL | re.MULTILINE) re.DOTALL | re.MULTILINE)
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
def _rel_time(mtime): def _rel_time(mtime):
d = int(time.time() - mtime) d = int(time.time() - mtime)
@@ -38,6 +40,32 @@ def _first_user(pairs):
if s and not s.startswith('###'): return s if s and not s.startswith('###'): return s
return '' return ''
def _last_summary(pairs):
for _, response_body in reversed(pairs):
try:
blocks = ast.literal_eval(response_body)
except Exception:
continue
if not isinstance(blocks, list):
continue
text_parts = []
for block in blocks:
if isinstance(block, dict) and block.get('type') == 'text':
text = block.get('text', '')
if isinstance(text, str) and text:
text_parts.append(text)
match = _SUMMARY_RE.search('\n'.join(text_parts))
if match:
summary = match.group(1).strip()
if summary:
return summary
return ''
def _preview_text(pairs):
return _last_summary(pairs) or _first_user(pairs)
def _parse_native_history(pairs): def _parse_native_history(pairs):
history = [] history = []
for p, r in pairs: for p, r in pairs:
@@ -60,16 +88,82 @@ def list_sessions(exclude_pid=None):
out = [] out = []
for f in files: for f in files:
try: try:
content = open(f, encoding='utf-8', errors='replace').read() with open(f, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception: continue except Exception: continue
pairs = _pairs(content) pairs = _pairs(content)
if not pairs: continue if not pairs: continue
out.append((f, os.path.getmtime(f), _first_user(pairs), len(pairs))) out.append((f, os.path.getmtime(f), _preview_text(pairs), len(pairs)))
out.sort(key=lambda x: x[1], reverse=True) out.sort(key=lambda x: x[1], reverse=True)
return out return out
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])') _MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s) def _escape_md(s): return _MD_ESCAPE_RE.sub(r'\\\1', s)
def _agent_clients(agent):
clients = []
for client in getattr(agent, 'llmclients', []) or []:
if client not in clients:
clients.append(client)
current = getattr(agent, 'llmclient', None)
if current is not None and current not in clients:
clients.insert(0, current)
return clients
def _replace_backend_history(agent, history):
backend = getattr(getattr(agent, 'llmclient', None), 'backend', None)
if backend is not None and hasattr(backend, 'history'):
backend.history = list(history or [])
def _current_log_path(pid=None):
pid = os.getpid() if pid is None else pid
return os.path.join(_LOG_DIR, f'model_responses_{pid}.txt')
def _snapshot_current_log(pid=None):
"""Persist current PID log as a standalone recoverable snapshot, then clear it."""
path = _current_log_path(pid)
if not os.path.isfile(path):
return None
try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception:
return None
if not _pairs(content):
return None
os.makedirs(_LOG_DIR, exist_ok=True)
pid = os.getpid() if pid is None else pid
stamp = time.strftime('%Y%m%d_%H%M%S')
snapshot = os.path.join(_LOG_DIR, f'model_responses_snapshot_{pid}_{stamp}_{time.time_ns() % 1_000_000_000:09d}.txt')
with open(snapshot, 'w', encoding='utf-8', errors='replace') as fh:
fh.write(content)
with open(path, 'w', encoding='utf-8', errors='replace'):
pass
return snapshot
def reset_conversation(agent, message='🆕 已开启新对话,当前上下文已清空'):
"""Abort current work and clear all known frontend-visible conversation state."""
try:
agent.abort()
except Exception:
pass
_snapshot_current_log()
if hasattr(agent, 'history'):
agent.history = []
for client in _agent_clients(agent):
backend = getattr(client, 'backend', None)
if backend is not None and hasattr(backend, 'history'):
backend.history = []
if hasattr(client, 'last_tools'):
client.last_tools = ''
if hasattr(agent, 'handler'):
agent.handler = None
return message
def format_list(sessions, limit=20): def format_list(sessions, limit=20):
if not sessions: return '❌ 没有可恢复的历史会话' if not sessions: return '❌ 没有可恢复的历史会话'
lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', ''] lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', '']
@@ -80,7 +174,9 @@ def format_list(sessions, limit=20):
def restore(agent, path): def restore(agent, path):
"""Restore session at path. Returns (msg, is_full).""" """Restore session at path. Returns (msg, is_full)."""
try: content = open(path, encoding='utf-8', errors='replace').read() try:
with open(path, encoding='utf-8', errors='replace') as fh:
content = fh.read()
except Exception as e: return f'❌ 读取失败: {e}', False except Exception as e: return f'❌ 读取失败: {e}', False
pairs = _pairs(content) pairs = _pairs(content)
if not pairs: return f'{os.path.basename(path)} 为空或格式不符', False if not pairs: return f'{os.path.basename(path)} 为空或格式不符', False
@@ -88,7 +184,7 @@ def restore(agent, path):
name = os.path.basename(path) name = os.path.basename(path)
if history is not None: if history is not None:
agent.abort() agent.abort()
agent.llmclient.backend.history = history _replace_backend_history(agent, history)
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name}\n(已写入 backend.history可直接继续)', True return f'✅ 已恢复 {len(pairs)} 轮完整对话({name}\n(已写入 backend.history可直接继续)', True
from chatapp_common import _restore_native_history, _restore_text_pairs from chatapp_common import _restore_native_history, _restore_text_pairs
summary = _restore_text_pairs(content) or _restore_native_history(content) summary = _restore_text_pairs(content) or _restore_native_history(content)
@@ -111,10 +207,31 @@ def handle(agent, query, display_queue):
if not (0 <= idx < len(sessions)): if not (0 <= idx < len(sessions)):
display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)}', 'source': 'system'}) display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)}', 'source': 'system'})
return None return None
reset_conversation(agent, message=None)
msg, _ = restore(agent, sessions[idx][0]) msg, _ = restore(agent, sessions[idx][0])
display_queue.put({'done': msg, 'source': 'system'}) display_queue.put({'done': msg, 'source': 'system'})
return None return None
return query return query
def handle_frontend_command(agent, query, exclude_pid=None):
"""Frontend-friendly /continue entry that returns text directly."""
s = (query or '').strip()
exclude_pid = os.getpid() if exclude_pid is None else exclude_pid
if s == '/continue':
return format_list(list_sessions(exclude_pid=exclude_pid))
m = re.match(r'/continue\s+(\d+)\s*$', s)
if not m:
return '用法: /continue 或 /continue N'
sessions = list_sessions(exclude_pid=exclude_pid)
idx = int(m.group(1)) - 1
if not (0 <= idx < len(sessions)):
return f'❌ 索引越界(有效范围 1-{len(sessions)}'
reset_conversation(agent, message=None)
msg, _ = restore(agent, sessions[idx][0])
return msg
def install(cls): def install(cls):
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch.""" """Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
orig = cls._handle_slash_cmd orig = cls._handle_slash_cmd

View File

@@ -5,6 +5,7 @@ sys.path.insert(0, PROJECT_ROOT)
os.chdir(PROJECT_ROOT) os.chdir(PROJECT_ROOT)
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
from frontends.chatapp_common import format_restore from frontends.chatapp_common import format_restore
from frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation
from llmcore import mykeys from llmcore import mykeys
import lark_oapi as lark import lark_oapi as lark
@@ -508,11 +509,9 @@ def handle_command(open_id, cmd, chat_id=None):
agent.abort() agent.abort()
_send_cmd_response("正在停止...") _send_cmd_response("正在停止...")
elif cmd == "/new": elif cmd == "/new":
agent.abort() _send_cmd_response(reset_conversation(agent))
agent.history = []
_send_cmd_response("已清空当前共享上下文")
elif cmd == "/help": elif cmd == "/help":
_send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/new - 开启新对话\n/help - 显示帮助") _send_cmd_response("命令列表:\n/stop - 停止当前任务\n/status - 查看状态\n/restore - 恢复上次对话历史\n/continue - 列出可恢复会话\n/continue [n] - 恢复第 n 个会话\n/new - 开启新对话并清空当前上下文\n/help - 显示帮助")
elif cmd == "/status": elif cmd == "/status":
_send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}") _send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}")
elif cmd == "/restore": elif cmd == "/restore":
@@ -526,6 +525,8 @@ def handle_command(open_id, cmd, chat_id=None):
_send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)") _send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
except Exception as e: except Exception as e:
_send_cmd_response(f"恢复失败: {e}") _send_cmd_response(f"恢复失败: {e}")
elif cmd.startswith("/continue"):
_send_cmd_response(handle_continue_frontend(agent, cmd))
else: else:
_send_cmd_response(f"未知命令: {cmd}") _send_cmd_response(f"未知命令: {cmd}")

View File

@@ -15,6 +15,7 @@ import streamlit as st
import time, json, re, threading, queue import time, json, re, threading, queue
from agentmain import GeneraticAgent from agentmain import GeneraticAgent
import chatapp_common # activate /continue command (monkey patches GeneraticAgent) import chatapp_common # activate /continue command (monkey patches GeneraticAgent)
from continue_cmd import handle_frontend_command, reset_conversation
st.set_page_config(page_title="Cowork", layout="wide") st.set_page_config(page_title="Cowork", layout="wide")
@@ -174,6 +175,31 @@ _js_ime_fix = ("" if os.name == 'nt' else
_embed_html(f'<script>{_js_scroll_fix};{_js_ime_fix}</script>', height=0) _embed_html(f'<script>{_js_scroll_fix};{_js_ime_fix}</script>', height=0)
if prompt := st.chat_input("any task?"): if prompt := st.chat_input("any task?"):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
cmd = (prompt or "").strip()
if cmd == "/new":
st.session_state.messages = [{"role": "assistant", "content": reset_conversation(agent), "time": ts}]
st.session_state.streaming = False
st.session_state.stopping = False
st.session_state.display_queue = None
st.session_state.partial_response = ""
st.session_state.reply_ts = ""
st.session_state.current_prompt = ""
st.session_state.last_reply_time = int(time.time())
st.rerun()
if cmd.startswith("/continue"):
st.session_state.messages = list(st.session_state.messages) + [
{"role": "user", "content": cmd, "time": ts},
{"role": "assistant", "content": handle_frontend_command(agent, cmd), "time": ts},
]
st.session_state.streaming = False
st.session_state.stopping = False
st.session_state.display_queue = None
st.session_state.partial_response = ""
st.session_state.reply_ts = ""
st.session_state.current_prompt = ""
st.session_state.last_reply_time = int(time.time())
st.rerun()
st.session_state.messages.append({"role": "user", "content": prompt}) st.session_state.messages.append({"role": "user", "content": prompt})
if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk') 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("user"): st.markdown(prompt)