feat(stapp): add /export command (clip/file/all) (#322)
This commit is contained in:
1 parent
3c5c99fd6d
commit
9024af7499
2 files changed
+111
No files matched your search
@@ -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/<name>.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
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user