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
|
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` 个可恢复会话
|
||||||
|
|
||||||
|
|
||||||
## 📊 与同类产品对比
|
## 📊 与同类产品对比
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user