diff --git a/frontends/stapp2.py b/frontends/stapp2.py index 1d7968f5..49fb0fff 100644 --- a/frontends/stapp2.py +++ b/frontends/stapp2.py @@ -1,3 +1,14 @@ +""" +GenericAgent Streamlit UI (stapp2) + +Upstream baseline: lsdefine/GenericAgent frontends/stapp2.py +Extensions: see stapp2_extensions.py (attachments, sidebar extras, upload UI) + +Code review | 审查顺序 + 1. stapp2_extensions.py — attachment & sidebar enhancements + 2. stapp2.py — upstream shell + turn expanders (Module F) +""" + import os, sys import html if sys.stdout is None: sys.stdout = open(os.devnull, "w") @@ -18,9 +29,11 @@ from datetime import datetime from agentmain import GeneraticAgent +from frontends import stapp2_extensions as ext + st.set_page_config(page_title="Cowork", layout="wide") -# ─── Anthropic Light Theme CSS ─── +# ── Upstream: Anthropic light theme CSS | 上游:Anthropic 浅色主题 ───────── ANTHROPIC_CSS = """ """ +# ── Upstream: sidebar selectbox width fix JS | 上游:侧栏下拉框宽度修正 ─── ANTHROPIC_SELECTBOX_SCRIPT = """
""" +# ── Upstream: agent init & theme helpers | 上游:Agent 与主题工具 ───────── + @st.cache_resource def init(): agent = GeneraticAgent() @@ -941,22 +957,28 @@ def build_header_agent_badge_script() -> str: """ +# ── App bootstrap | 应用启动 ───────────────────────────────────────────── + agent = init() +_UPSTREAM_SESSION_DEFAULTS = { + 'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None, + 'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no, + 'autonomous_enabled': False, 'messages': [], +} + def init_session_state(): - for key, value in { - 'agent_name': 'GenericAgent', 'streaming': False, 'stopping': False, 'display_queue': None, - 'partial_response': '', 'reply_ts': '', 'current_prompt': '', 'selected_llm_idx': agent.llm_no, - 'autonomous_enabled': False, 'messages': [], - }.items(): st.session_state.setdefault(key, value) + for key, value in _UPSTREAM_SESSION_DEFAULTS.items(): + st.session_state.setdefault(key, value) + ext.register_extension_session_state() init_session_state() -# Inject Anthropic theme st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True) st.markdown(build_dynamic_font_css(110.0), unsafe_allow_html=True) _embed_html(ANTHROPIC_SELECTBOX_SCRIPT, height=0, width=0) _embed_html(build_header_agent_badge_script(), height=0, width=0) +ext.inject_extension_assets() st.session_state.agent_name = 'Generic Agent' with st.chat_message("assistant"): @@ -964,6 +986,7 @@ def init_session_state(): st.write("欢迎使用GenericAgent~") +# ── Upstream: sidebar (LLM switch) + fork extras | 上游侧栏 + fork 扩展 ── @st.fragment def render_sidebar(): llm_options, current_idx = agent.list_llms(), agent.llm_no @@ -978,13 +1001,13 @@ def render_sidebar(): st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") st.rerun() st.divider() - if st.button("重新注入System Prompt"): - agent.llmclient.last_tools = '' - st.toast("下次将重新注入System Prompt") + ext.render_sidebar_extras(agent) with st.sidebar: render_sidebar() +# ── Upstream: agent task & chat loop | 上游:任务与对话循环 ─────────────── + def start_agent_task(prompt): st.session_state.display_queue = agent.put_task(prompt, source="user") st.session_state.streaming, st.session_state.stopping, st.session_state.partial_response = True, False, '' @@ -1015,17 +1038,44 @@ def poll_agent_output(max_items=20): def _get_response_segments(text): return [p for p in re.split(r'(?=\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)', text) if p.strip()] or [text] -def render_message(role, content, ts='', unsafe_allow_html=True): + +_TURN_RUNNING_RE = re.compile(r'^\*\*LLM Running \(Turn \d+\)') + + +def _is_intermediate_turn_segment(segment: str, *, is_last: bool) -> bool: + """Fold non-final **LLM Running (Turn N)** blocks into expanders | 将非最终 Turn 块折叠为 expander""" + return (not is_last) and bool(_TURN_RUNNING_RE.match(segment.strip())) + + +def _turn_expander_label(content: str) -> str: + m = re.match(r'^\*\*LLM Running \(Turn (\d+)\)', content.strip()) + return f"Turn {m.group(1)} · 推理过程" if m else "推理过程" + + +def render_message(role, content, ts='', unsafe_allow_html=True, intermediate=False): with st.chat_message(role): if ts: st.markdown(f'', unsafe_allow_html=True) - st.markdown(content, unsafe_allow_html=unsafe_allow_html) + if intermediate: + with st.expander(_turn_expander_label(content), expanded=False): + st.markdown(content, unsafe_allow_html=unsafe_allow_html) + else: + st.markdown(content, unsafe_allow_html=unsafe_allow_html) + def finish_streaming_message(): reply_ts = st.session_state.reply_ts - st.session_state.messages.extend({"role": "assistant", "content": seg, "time": reply_ts} for seg in _get_response_segments(st.session_state.partial_response)) + segments = _get_response_segments(st.session_state.partial_response) + for i, seg in enumerate(segments): + st.session_state.messages.append({ + "role": "assistant", + "content": seg, + "time": reply_ts, + "intermediate": _is_intermediate_turn_segment(seg, is_last=(i == len(segments) - 1)), + }) st.session_state.last_reply_time = int(time.time()) st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = '' + def render_streaming_area(): if not st.session_state.streaming: return with st.container(): @@ -1035,15 +1085,34 @@ def render_streaming_area(): reply_ts = st.session_state.reply_ts with st.empty().container(): segments = _get_response_segments(st.session_state.partial_response) - for i, seg in enumerate(segments): render_message("assistant", seg + ("" if i < len(segments) - 1 else "▌"), ts=reply_ts, unsafe_allow_html=False) + for i, seg in enumerate(segments): + is_last = (i == len(segments) - 1) + render_message( + "assistant", + seg + ("" if not is_last else "▌"), + ts=reply_ts, + unsafe_allow_html=False, + intermediate=_is_intermediate_turn_segment(seg, is_last=is_last), + ) if poll_agent_output(): finish_streaming_message() else: time.sleep(0.2) st.rerun() -for msg in st.session_state.messages: render_message(msg["role"], msg["content"], ts=msg.get("time", ""), unsafe_allow_html=True) -if st.session_state.streaming: render_streaming_area() -if prompt := st.chat_input("请输入指令", disabled=st.session_state.streaming): - st.session_state.messages.append({"role": "user", "content": prompt, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}) - start_agent_task(prompt) + +# ── Main UI loop | 主界面 ───────────────────────────────────────────────── + +for msg in st.session_state.messages: + render_message(msg["role"], msg["content"], ts=msg.get("time", ""), + unsafe_allow_html=True, intermediate=msg.get("intermediate", False)) + +if st.session_state.streaming: + render_streaming_area() + +if ext.process_paste_signal() or ext.process_delete_signal(): st.rerun() +ext.render_signal_inputs() +prompt = ext.render_attachment_input_row(streaming=st.session_state.streaming) + +if prompt: + ext.handle_user_submit(prompt, start_task=start_agent_task) diff --git a/frontends/stapp2_extensions.py b/frontends/stapp2_extensions.py new file mode 100644 index 00000000..617b0851 --- /dev/null +++ b/frontends/stapp2_extensions.py @@ -0,0 +1,579 @@ +""" +stapp2_extensions — UI enhancements for stapp2 (vs upstream GenericAgent/frontends/stapp2.py) + +Attachments, paste-to-upload, sidebar controls, compact uploader layout. +stapp2_extensions — stapp2 增强模块(附件、粘贴上传、侧栏扩展、紧凑上传布局) + +Code review map | 审查对照 +──────────────────────────────────────────────────────────── +Module A i18n & paths — GA_LANG sidebar labels +Module B inject CSS/JS — file uploader layout, paste/delete hidden inputs +Module C attachments — upload, paste image, thumbnails, prompt building +Module D sidebar extras — force stop, desktop pet, reinject history, autonomy +Module E chat input row — [upload | chat_input] + send-with-attachments +Module F turn UI (in stapp2) — intermediate segment expanders (see stapp2.py) +""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import sys +import threading +import time +from datetime import datetime +from io import BytesIO +from urllib.parse import quote +from urllib.request import urlopen + +import streamlit as st + +# ── Module A: i18n & paths | 国际化与路径 ───────────────────────────────── + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 +PLACEHOLDER_PASTE = "__paste_image_signal__" +PLACEHOLDER_DELETE = "__delete_file_signal__" +TEXT_FILE_SUFFIXES = ( + ".txt", ".md", ".py", ".json", ".log", ".csv", ".yaml", ".yml", ".js", ".ts", ".sql", +) + +_I18N = { + "zh": { + "force_stop": "强行停止任务", + "reinject_tools": "重新注入System Prompt", + "desktop_pet": "🐱 桌面宠物", + }, + "en": { + "force_stop": "Force Stop", + "reinject_tools": "Reinject Tools", + "desktop_pet": "🐱 Desktop Pet", + }, +} + + +def get_lang() -> str: + lang = os.environ.get("GA_LANG", "zh") + return lang if lang in ("zh", "en") else "zh" + + +def t(key: str) -> str: + lang = get_lang() + return _I18N.get(lang, _I18N["zh"]).get(key, key) + + +EXTENSION_SESSION_DEFAULTS = { + "uploaded_files": [], # list[{name, type, size, content}] + "file_uploader_key": 0, + "preview_file_idx": None, +} + + +def register_extension_session_state() -> None: + """Register extension session_state keys | 注册扩展模块专用 session 键""" + for key, value in EXTENSION_SESSION_DEFAULTS.items(): + st.session_state.setdefault(key, value) + + +# ── Module B: inject CSS/JS | 注入样式与脚本 ───────────────────────────── + +FILE_UPLOAD_CSS = """ + +""" + +# Off-screen signal inputs for paste/delete (Streamlit widget bridge) +# 屏幕外信号输入框:粘贴/删除附件时由 JS 写入以触发 rerun +PASTE_HIDDEN_INPUT_CSS = f""" + +""" + + +def build_paste_listener_script() -> str: + """Clipboard image → hidden text_input → session_state | 剪贴板图片写入隐藏输入框""" + return f""" + +""" + + +def _embed_html(html: str, **kwargs) -> None: + try: + from streamlit import iframe as st_iframe + + st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kwargs.items()}) + except (ImportError, AttributeError): + from streamlit.components.v1 import html as components_html + + components_html(html, **kwargs) + + +def inject_extension_assets() -> None: + """Inject extension CSS/JS after upstream theme | 在上游主题之后注入扩展样式与脚本""" + st.markdown(FILE_UPLOAD_CSS, unsafe_allow_html=True) + st.markdown(PASTE_HIDDEN_INPUT_CSS, unsafe_allow_html=True) + _embed_html(build_paste_listener_script(), height=0, width=0) + + +# ── Module C: attachments | 附件处理 ───────────────────────────────────── + +def save_uploaded_file(file_dict: dict) -> str: + """Save to temp/uploaded/, return absolute path | 保存到 temp/uploaded/ 并返回绝对路径""" + os.makedirs("temp/uploaded", exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9._\-一-龥]", "_", file_dict["name"]) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + saved_path = os.path.join("temp", "uploaded", f"{timestamp}_{safe_name}") + try: + with open(saved_path, "wb") as f: + f.write(file_dict["content"]) + return os.path.abspath(saved_path) + except Exception as e: + print(f"[ERROR] Failed to save {file_dict['name']}: {e}") + return "" + + +def generate_thumbnail(file_dict: dict, size=(80, 80)) -> str: + """Thumbnail as data URI or emoji | 缩略图:图片为 data URI,其它为 emoji""" + if file_dict["type"].startswith("image/"): + try: + from PIL import Image + + img = Image.open(BytesIO(file_dict["content"])) + img.thumbnail(size, Image.Resampling.LANCZOS) + buf = BytesIO() + img.save(buf, format="PNG") + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/png;base64,{b64}" + except Exception as e: + print(f"[ERROR] Thumbnail generation failed: {e}") + return "📷" + ext = os.path.splitext(file_dict["name"])[1].lower() + icon_map = { + ".pdf": "📄", ".txt": "📝", ".md": "📝", ".doc": "📄", ".docx": "📄", + ".xls": "📊", ".xlsx": "📊", ".csv": "📊", + ".zip": "📦", ".rar": "📦", ".7z": "📦", + ".py": "🐍", ".js": "📜", ".json": "📋", ".xml": "📋", + ".mp3": "🎵", ".wav": "🎵", ".mp4": "🎬", ".avi": "🎬", + } + return icon_map.get(ext, "📎") + + +def build_prompt_with_files(prompt: str, files: list) -> tuple[str, str]: + """Return (agent_prompt, display_prompt) | 返回 (发给 Agent 的 prompt, 界面展示用 prompt)""" + if not files: + return prompt, prompt + + attachment_info = ["\n\n[用户上传附件 — 文件已保存到本地磁盘,可用 file_read 工具读取]"] + for f in files: + saved_path = save_uploaded_file(f) + if not saved_path: + continue + if f["type"].startswith("image/"): + b64 = base64.b64encode(f["content"]).decode() + attachment_info.append( + f"\n- [图片附件] {f['name']} ({f['size']} bytes)" + f"\n 磁盘路径: {saved_path}" + f"\n data:{f['type']};base64,{b64[:100]}...(truncated)" + ) + elif f["name"].endswith(TEXT_FILE_SUFFIXES): + try: + text = f["content"].decode("utf-8", errors="replace") + max_chars = 6000 + attachment_info.append( + f"\n--- 文本文件: {f['name']} ({f['size']} bytes) ---" + f"\n磁盘路径: {saved_path}" + f"\n{text[:max_chars]}" + + ("\n[内容已截断,请用 file_read 读取完整内容]" if len(text) > max_chars else "") + ) + except Exception: + attachment_info.append(f"\n- 文件: {f['name']} (无法解码为文本)\n 磁盘路径: {saved_path}") + else: + attachment_info.append(f"\n- 文件: {f['name']} ({f['size']} bytes)\n 磁盘路径: {saved_path}") + + agent_prompt = prompt + "\n".join(attachment_info) + display_prompt = f"{prompt}\n\n📎 附件: {', '.join(f['name'] for f in files)}" + return agent_prompt, display_prompt + + +def render_file_thumbnails() -> None: + """Thumbnail strip with JS delete bridge | 缩略图条,删除经 JS 写入隐藏输入框""" + files = st.session_state.uploaded_files + if not files: + return + + cards_html = [] + for idx, f in enumerate(files): + thumb = generate_thumbnail(f) + safe_name = ( + f["name"].replace("&", "&").replace('"', """) + .replace("'", "'").replace("<", "<") + ) + name_display = safe_name[:18] + if thumb.startswith("data:image"): + inner = f'