diff --git a/frontends/export_cmd.py b/frontends/export_cmd.py new file mode 100644 index 00000000..4d030c50 --- /dev/null +++ b/frontends/export_cmd.py @@ -0,0 +1,78 @@ +"""`/export` command: export last assistant reply / locate full conversation log. +Pure functions, no Qt deps. UI wiring lives in the frontend file. +""" +import os +import re +import sys +from datetime import datetime + +from continue_cmd import _pairs, _assistant_text + +_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp') +_BACKTICK_RUN_RE = re.compile(r'`+') + + +def wrap_for_clipboard(text, language='markdown'): + """Wrap text in a markdown code fence that survives nested fences. + + CommonMark closes a fenced block on a line whose backtick run is at least + as long as the opening one. Pick `max(3, longest_inner_run + 1)` so any + backticks inside `text` are strictly shorter than the outer fence. + """ + longest = max((len(m.group(0)) for m in _BACKTICK_RUN_RE.finditer(text)), default=0) + fence = '`' * max(3, longest + 1) + return f"{fence}{language}\n{text}\n{fence}" + + +def last_assistant_text(agent): + """Last assistant reply as joined plain text from `agent.log_path`. + + Reads the model_responses log, takes the most recent === Response === block, + and joins only the `text` fields (skips thinking/tool_use/tool_result). + + Returns None when: + - the LLM backend's history is empty (fresh agent or just-`/new`'d — + log_path may still hold prior-session content that no longer belongs + to the current conversation) + - the log is missing or unreadable + - there's no Response yet, or the last Response holds no text blocks + (e.g. tool-only turn) + + OS errors are logged to stderr — small failure radius, the UI falls back + gracefully. + """ + if not (agent.llmclient and agent.llmclient.backend.history): + return None + log = agent.log_path + if not os.path.isfile(log): + return None + try: + with open(log, encoding='utf-8', errors='replace') as f: + content = f.read() + except OSError as e: + print(f"[export_cmd] failed to read {log}: {e}", file=sys.stderr) + return None + pairs = _pairs(content) + if not pairs: + return None + text = _assistant_text(pairs[-1][1]) + return text if text.strip() else None + + +def export_to_temp(text, name): + """Write text to temp/.md, overwriting on collision. Returns full path. + + `name` is sanitized via os.path.basename to keep the write inside temp/. + Empty/whitespace name falls back to a timestamp. `.md` is appended if + the user-supplied name has no extension. + """ + os.makedirs(_TEMP_DIR, exist_ok=True) + safe = os.path.basename((name or '').strip()) + if not safe: + safe = f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + if not os.path.splitext(safe)[1]: + safe = safe + '.md' + path = os.path.join(_TEMP_DIR, safe) + with open(path, 'w', encoding='utf-8') as f: + f.write(text) + return path diff --git a/frontends/stapp.py b/frontends/stapp.py index 85987222..83a6d33c 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -17,6 +17,7 @@ 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 +from export_cmd import last_assistant_text, export_to_temp, wrap_for_clipboard st.set_page_config(page_title="Cowork", layout="wide") @@ -293,6 +294,38 @@ if prompt := st.chat_input("any task?"): {"role": "assistant", "content": answer, "time": ts}, ] st.rerun() # preserve display_queue/partial_response so resume path drains the running main task + if cmd.startswith("/export"): + parts = cmd.split(maxsplit=1) + sub = parts[1].strip() if len(parts) > 1 else "" + sub_lower = sub.lower() + if not sub: + result = ( + "**选择导出方式:**\n\n" + "- `/export clip` — 整理到代码块中\n" + "- `/export <文件名>` — 导出到 `temp/<文件名>`(默认 .md 后缀)\n" + "- `/export all` — 显示完整对话日志路径" + ) + elif sub_lower == "all": + log = agent.log_path + result = (f"📂 完整对话日志:\n\n`{log}`" if os.path.isfile(log) + else f"❌ 当前会话尚无日志文件") + else: + text = last_assistant_text(agent) + if not text: + result = "❌ 还没有模型回复可导出" + elif sub_lower in ("clip", "copy"): + result = f"📋 最后一轮回复(点代码块右上角 📋 复制):\n\n{wrap_for_clipboard(text)}" + else: + try: + path = export_to_temp(text, sub) + result = f"✅ 已导出:\n\n`{path}`" + except Exception as e: + result = f"❌ 导出失败: {e}" + st.session_state.messages = list(st.session_state.messages) + [ + {"role": "user", "content": cmd, "time": ts}, + {"role": "assistant", "content": result, "time": ts}, + ] + _reset_and_rerun() # Regular prompt: any in-flight task will be aborted by the finally block in # agent_backend_stream when StopException interrupts the prior generator. st.session_state.messages.append({"role": "user", "content": prompt})