diff --git a/README.md b/README.md index 0754667..eb4c964 100644 --- a/README.md +++ b/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` 个可恢复会话 + ## 📊 与同类产品对比 diff --git a/frontends/chatapp_common.py b/frontends/chatapp_common.py index dcd6cf2..1f8b3e4 100644 --- a/frontends/chatapp_common.py +++ b/frontends/chatapp_common.py @@ -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">.*?" 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) diff --git a/frontends/continue_cmd.py b/frontends/continue_cmd.py index 7f57dc4..fc8fa59 100644 --- a/frontends/continue_cmd.py +++ b/frontends/continue_cmd.py @@ -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'\s*(.*?)\s*', 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 diff --git a/frontends/fsapp.py b/frontends/fsapp.py index e281a7c..659881a 100644 --- a/frontends/fsapp.py +++ b/frontends/fsapp.py @@ -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}") diff --git a/frontends/stapp.py b/frontends/stapp.py index 466311b..673d137 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -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'', 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)