feat: add continue/new support to chat frontends
This commit is contained in:
16
README.md
16
README.md
@@ -115,6 +115,14 @@ python frontends/qtapp.py # Qt-based desktop app
|
||||
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
|
||||
|
||||
@@ -381,6 +389,14 @@ python frontends/qtapp.py # 基于 Qt 的桌面应用
|
||||
streamlit run frontends/stapp2.py # 另一种 Streamlit 风格 UI
|
||||
```
|
||||
|
||||
### 通用聊天命令
|
||||
|
||||
默认通过 `python launch.pyw` 启动的 Streamlit 桌面 UI,以及 QQ / 飞书 / 企业微信 / 钉钉前端,都支持以下命令:
|
||||
|
||||
- `/new` - 开启新对话并清空当前上下文
|
||||
- `/continue` - 列出可恢复会话快照
|
||||
- `/continue N` - 恢复第 `N` 个可恢复会话
|
||||
|
||||
|
||||
## 📊 与同类产品对比
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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."
|
||||
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__)))
|
||||
@@ -237,6 +237,8 @@ class AgentChatMixin:
|
||||
async def handle_command(self, chat_id, cmd, **ctx):
|
||||
parts = (cmd or "").split()
|
||||
op = (parts[0] if parts else "").lower()
|
||||
if op == "/help":
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
if op == "/stop":
|
||||
state = self.user_tasks.get(chat_id)
|
||||
if state:
|
||||
@@ -268,10 +270,10 @@ class AgentChatMixin:
|
||||
return await self.send_text(chat_id, f"✅ 已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)", **ctx)
|
||||
except Exception as e:
|
||||
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":
|
||||
self.agent.abort()
|
||||
self.agent.history = []
|
||||
return await self.send_text(chat_id, "🆕 已清空当前共享上下文", **ctx)
|
||||
return await self.send_text(chat_id, _reset_conversation(self.agent), **ctx)
|
||||
return await self.send_text(chat_id, HELP_TEXT, **ctx)
|
||||
|
||||
async def run_agent(self, chat_id, text, **ctx):
|
||||
@@ -304,5 +306,5 @@ class AgentChatMixin:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
Pure functions + one `install(cls)` monkey-patch entry. No side effects at import.
|
||||
"""
|
||||
import ast, glob, json, os, re, time
|
||||
_LOG_GLOB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'temp', 'model_responses', 'model_responses_*.txt')
|
||||
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'temp', 'model_responses')
|
||||
_LOG_GLOB = os.path.join(_LOG_DIR, 'model_responses_*.txt')
|
||||
_BLOCK_RE = re.compile(r'^=== (Prompt|Response) ===.*?\n(.*?)(?=^=== (?:Prompt|Response) ===|\Z)',
|
||||
re.DOTALL | re.MULTILINE)
|
||||
_SUMMARY_RE = re.compile(r'<summary>\s*(.*?)\s*</summary>', re.DOTALL)
|
||||
|
||||
def _rel_time(mtime):
|
||||
d = int(time.time() - mtime)
|
||||
@@ -38,6 +40,32 @@ def _first_user(pairs):
|
||||
if s and not s.startswith('###'): return s
|
||||
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):
|
||||
history = []
|
||||
for p, r in pairs:
|
||||
@@ -60,16 +88,82 @@ def list_sessions(exclude_pid=None):
|
||||
out = []
|
||||
for f in files:
|
||||
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
|
||||
pairs = _pairs(content)
|
||||
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)
|
||||
return out
|
||||
_MD_ESCAPE_RE = re.compile(r'([\\`*_\[\]])')
|
||||
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):
|
||||
if not sessions: return '❌ 没有可恢复的历史会话'
|
||||
lines = ['**可恢复会话**(输入 `/continue N` 恢复第 N 个):', '']
|
||||
@@ -80,7 +174,9 @@ def format_list(sessions, limit=20):
|
||||
|
||||
def restore(agent, path):
|
||||
"""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
|
||||
pairs = _pairs(content)
|
||||
if not pairs: return f'❌ {os.path.basename(path)} 为空或格式不符', False
|
||||
@@ -88,7 +184,7 @@ def restore(agent, path):
|
||||
name = os.path.basename(path)
|
||||
if history is not None:
|
||||
agent.abort()
|
||||
agent.llmclient.backend.history = history
|
||||
_replace_backend_history(agent, history)
|
||||
return f'✅ 已恢复 {len(pairs)} 轮完整对话({name})\n(已写入 backend.history,可直接继续)', True
|
||||
from chatapp_common import _restore_native_history, _restore_text_pairs
|
||||
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)):
|
||||
display_queue.put({'done': f'❌ 索引越界(有效范围 1-{len(sessions)})', 'source': 'system'})
|
||||
return None
|
||||
reset_conversation(agent, message=None)
|
||||
msg, _ = restore(agent, sessions[idx][0])
|
||||
display_queue.put({'done': msg, 'source': 'system'})
|
||||
return None
|
||||
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):
|
||||
"""Wrap cls._handle_slash_cmd so /continue is handled before original dispatch."""
|
||||
orig = cls._handle_slash_cmd
|
||||
|
||||
@@ -5,6 +5,7 @@ sys.path.insert(0, PROJECT_ROOT)
|
||||
os.chdir(PROJECT_ROOT)
|
||||
from agentmain import GeneraticAgent
|
||||
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
|
||||
|
||||
import lark_oapi as lark
|
||||
@@ -508,11 +509,9 @@ def handle_command(open_id, cmd, chat_id=None):
|
||||
agent.abort()
|
||||
_send_cmd_response("正在停止...")
|
||||
elif cmd == "/new":
|
||||
agent.abort()
|
||||
agent.history = []
|
||||
_send_cmd_response("已清空当前共享上下文")
|
||||
_send_cmd_response(reset_conversation(agent))
|
||||
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":
|
||||
_send_cmd_response(f"状态: {'空闲' if not agent.is_running else '运行中'}")
|
||||
elif cmd == "/restore":
|
||||
@@ -526,6 +525,8 @@ def handle_command(open_id, cmd, chat_id=None):
|
||||
_send_cmd_response(f"已恢复 {count} 轮对话\n来源: {fname}\n(仅恢复上下文,请输入新问题继续)")
|
||||
except Exception as e:
|
||||
_send_cmd_response(f"恢复失败: {e}")
|
||||
elif cmd.startswith("/continue"):
|
||||
_send_cmd_response(handle_continue_frontend(agent, cmd))
|
||||
else:
|
||||
_send_cmd_response(f"未知命令: {cmd}")
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import streamlit as st
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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})
|
||||
if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk')
|
||||
with st.chat_message("user"): st.markdown(prompt)
|
||||
|
||||
Reference in New Issue
Block a user