From 5f0667d815a6b896006432269c009c62de190947 Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Thu, 14 May 2026 12:10:18 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E5=8F=A0=E5=8A=A0=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20stapp2=20=E5=BC=80=E5=8F=91=E6=88=90=E6=9E=9C?= =?UTF-8?q?=E5=88=B0=20upstream/main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留文件上传/粘贴/缩略图删除功能 - 保留多轮推理中间步骤折叠功能 - 新增 stapp2_old.py 参考文件、start_streamlit.bat、test_files/ Co-Authored-By: Claude Sonnet 4.6 --- frontends/stapp2.py | 624 ++++++++++++++++++++++- frontends/stapp2_old.py | 1049 +++++++++++++++++++++++++++++++++++++++ start_streamlit.bat | 4 + test_files/test.txt | 9 + 4 files changed, 1671 insertions(+), 15 deletions(-) create mode 100644 frontends/stapp2_old.py create mode 100644 start_streamlit.bat create mode 100644 test_files/test.txt diff --git a/frontends/stapp2.py b/frontends/stapp2.py index 1d7968f5d..3e192412c 100644 --- a/frontends/stapp2.py +++ b/frontends/stapp2.py @@ -9,13 +9,16 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import streamlit as st -try: - from streamlit import iframe as _st_iframe # 1.56+ - _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) -except (ImportError, AttributeError): - from streamlit.components.v1 import html as _embed_html # ≤1.55 -import time, json, re, threading, queue +from streamlit.components.v1 import html as _cv1_html + +def _embed_html(html_str, height=1, width=None, scrolling=False): + """始终用 components.v1.html 嵌入 HTML 字符串(st.iframe 接受 URL,不能用于此处)""" + h = max(int(height), 1) if height is not None else 1 + w = int(width) if (width is not None and int(width) > 0) else None + return _cv1_html(html_str, width=w, height=h, scrolling=scrolling) +import time, json, re, threading, queue, base64 from datetime import datetime +from io import BytesIO from agentmain import GeneraticAgent st.set_page_config(page_title="Cowork", layout="wide") @@ -656,6 +659,182 @@ """ +FILE_UPLOAD_CSS = """ + +""" + +PASTE_HIDDEN_INPUT_CSS = """ + +""" + ANTHROPIC_SELECTBOX_SCRIPT = """
' + '' + ) + _embed_html(full_html, height=iframe_height) + + +@st.dialog("文件预览", width="large") +def preview_file_dialog(): + """文件预览模态对话框""" + idx = st.session_state.preview_file_idx + if idx is None or idx >= len(st.session_state.uploaded_files): + return + + f = st.session_state.uploaded_files[idx] + + st.subheader(f"📎 {f['name']}") + st.caption(f"类型: {f['type']} | 大小: {f['size']:,} bytes") + + if f['type'].startswith('image/'): + # 图片预览 + st.image(f['content'], use_container_width=True) + elif f['name'].endswith(('.txt', '.md', '.py', '.json', '.log', '.csv', '.yaml', '.yml', '.js', '.ts', '.sql')): + # 文本文件预览 + try: + text = f['content'].decode('utf-8', errors='replace') + st.code(text[:5000], language=None) + if len(text) > 5000: + st.info("内容已截断至前 5000 字符") + except Exception as e: + st.error(f"无法显示文件内容: {e}") + else: + # 其他文件显示信息 + st.info("此文件类型不支持预览") + st.json({ + "文件名": f['name'], + "类型": f['type'], + "大小": f"{f['size']:,} bytes", + }) + + if st.button("关闭", use_container_width=True): + st.session_state.preview_file_idx = None + st.rerun() + + def build_header_agent_badge_script() -> str: return """ """ + +def build_paste_listener_script() -> str: + """注入粘贴监听 JS:捕获剪贴板图片并写入隐藏信号输入框触发 Streamlit rerun。""" + return """ + +""" + + agent = init() def init_session_state(): @@ -948,15 +1390,21 @@ def init_session_state(): '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': [], + 'uploaded_files': [], # [{'name', 'type', 'size', 'content'}, ...] + 'file_uploader_key': 0, # 用于重置 file_uploader + 'preview_file_idx': None, # 当前预览的文件索引 }.items(): st.session_state.setdefault(key, value) init_session_state() # Inject Anthropic theme st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True) +st.markdown(FILE_UPLOAD_CSS, unsafe_allow_html=True) +st.markdown(PASTE_HIDDEN_INPUT_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) +_embed_html(build_paste_listener_script(), height=0, width=0) st.session_state.agent_name = 'Generic Agent' with st.chat_message("assistant"): @@ -1015,14 +1463,31 @@ 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): +def render_message(role, content, ts='', unsafe_allow_html=True, intermediate=False): with st.chat_message(role): - if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) - st.markdown(content, unsafe_allow_html=unsafe_allow_html) + if ts: + st.markdown(f'
{ts}
', unsafe_allow_html=True) + if intermediate: + m = re.match(r'^\*\*LLM Running \(Turn (\d+)\)', content.strip()) + label = f"Turn {m.group(1)} · 推理过程" if m else "推理过程" + with st.expander(label, 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): + is_intermediate = (i < len(segments) - 1) and bool( + re.match(r'^\*\*LLM Running \(Turn \d+\)', seg.strip()) + ) + st.session_state.messages.append({ + "role": "assistant", + "content": seg, + "time": reply_ts, + "intermediate": is_intermediate, + }) st.session_state.last_reply_time = int(time.time()) st.session_state.partial_response = st.session_state.reply_ts = st.session_state.current_prompt = '' @@ -1035,15 +1500,144 @@ 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) + is_intermediate = (not is_last) and bool( + re.match(r'^\*\*LLM Running \(Turn \d+\)', seg.strip()) + ) + render_message( + "assistant", + seg + ("" if not is_last else "▌"), + ts=reply_ts, + unsafe_allow_html=False, + intermediate=is_intermediate, + ) 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) +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 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) + +# ── 粘贴图片:隐藏信号输入框 + 处理逻辑 ────────────────────────────────── +_paste_val = st.session_state.get("paste_image_signal", "") +if _paste_val and _paste_val.startswith("data:image"): + try: + _header, _b64str = _paste_val.split(",", 1) + _mime = _header.split(":")[1].split(";")[0] # e.g. "image/png" + _ext = _mime.split("/")[1] # e.g. "png" + _content = base64.b64decode(_b64str) + if len(_content) <= 10 * 1024 * 1024: + _fname = f"pasted_{datetime.now().strftime('%H%M%S%f')[:12]}.{_ext}" + st.session_state.uploaded_files.append({ + 'name': _fname, + 'type': _mime, + 'size': len(_content), + 'content': _content, + }) + except Exception: + pass + st.session_state.paste_image_signal = "" + st.rerun() + +st.text_input( + "paste_signal", + value="", + key="paste_image_signal", + label_visibility="collapsed", + placeholder="__paste_image_signal__", +) + +# ── 删除文件信号 ────────────────────────────────────────────────────────── +_del_val = st.session_state.get("delete_file_signal", "") +if _del_val and _del_val.isdigit(): + idx = int(_del_val) + if 0 <= idx < len(st.session_state.uploaded_files): + st.session_state.uploaded_files.pop(idx) + st.session_state.delete_file_signal = "" + st.rerun() + +st.text_input( + "delete_signal", + value="", + key="delete_file_signal", + label_visibility="collapsed", + placeholder="__delete_file_signal__", +) + +# 1. 渲染文件缩略图(如果有上传文件) +render_file_thumbnails() + +# 2. 输入区域布局: [文件上传按钮] [聊天输入框] +col_upload, col_input = st.columns([0.08, 0.92]) + +with col_upload: + # 文件上传按钮(样式化为 + 按钮) + uploaded_files = st.file_uploader( + "上传文件", + accept_multiple_files=True, + label_visibility="collapsed", + key=f"file_uploader_{st.session_state.file_uploader_key}", + help="点击上传图片、文档等文件", + ) + + # 处理新上传的文件 + if uploaded_files: + for uf in uploaded_files: + # 检查是否已存在 + if any(f['name'] == uf.name for f in st.session_state.uploaded_files): + continue + + # 读取文件内容 + content = uf.read() + + # 文件大小限制: 10MB + if len(content) > 10 * 1024 * 1024: + st.warning(f"文件 {uf.name} 超过 10MB,已跳过") + continue + + # 添加到状态 + st.session_state.uploaded_files.append({ + 'name': uf.name, + 'type': uf.type if uf.type else 'application/octet-stream', + 'size': len(content), + 'content': content, + }) + + # 重置 uploader(避免重复处理) + st.session_state.file_uploader_key += 1 + st.rerun() + +with col_input: + # 聊天输入框 + prompt = st.chat_input("请输入指令", disabled=st.session_state.streaming) + + +# 4. 处理消息发送 +if prompt: + # 构建包含文件的完整提示 + files = st.session_state.uploaded_files + agent_prompt, display_prompt = build_prompt_with_files(prompt, files) + + # 添加用户消息(显示简化版) + st.session_state.messages.append({ + "role": "user", + "content": display_prompt, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + + # 发送给 agent(完整版,包含文件内容) + start_agent_task(agent_prompt) + + # 清空已上传文件 + st.session_state.uploaded_files.clear() + st.session_state.file_uploader_key += 1 + st.rerun() diff --git a/frontends/stapp2_old.py b/frontends/stapp2_old.py new file mode 100644 index 000000000..1d7968f5d --- /dev/null +++ b/frontends/stapp2_old.py @@ -0,0 +1,1049 @@ +import os, sys +import html +if sys.stdout is None: sys.stdout = open(os.devnull, "w") +if sys.stderr is None: sys.stderr = open(os.devnull, "w") +try: sys.stdout.reconfigure(errors='replace') +except: pass +try: sys.stderr.reconfigure(errors='replace') +except: pass +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import streamlit as st +try: + from streamlit import iframe as _st_iframe # 1.56+ + _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) +except (ImportError, AttributeError): + from streamlit.components.v1 import html as _embed_html # ≤1.55 +import time, json, re, threading, queue +from datetime import datetime +from agentmain import GeneraticAgent + +st.set_page_config(page_title="Cowork", layout="wide") + +# ─── Anthropic Light Theme CSS ─── +ANTHROPIC_CSS = """ + +""" + +ANTHROPIC_SELECTBOX_SCRIPT = """ +
+ +""" + +@st.cache_resource +def init(): + agent = GeneraticAgent() + if agent.llmclient is None: + st.error("⚠️ 未配置任何可用的 LLM 接口,请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。") + st.stop() + else: + threading.Thread(target=agent.run, daemon=True).start() + return agent + + +def build_dynamic_font_css(scale_percent: float) -> str: + root_percent = max(100.0, min(200.0, float(scale_percent))) + rem_scale = root_percent / 100.0 + return f""" + +""" + + +def build_dynamic_font_update_script(scale_percent: float) -> str: + css = json.dumps(build_dynamic_font_css(scale_percent)) + return f""" + +""" + + +def build_header_agent_badge_script() -> str: + return """ + +""" + +agent = init() + +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) + +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) + +st.session_state.agent_name = 'Generic Agent' +with st.chat_message("assistant"): + st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) + st.write("欢迎使用GenericAgent~") + + +@st.fragment +def render_sidebar(): + llm_options, current_idx = agent.list_llms(), agent.llm_no + st.session_state.selected_llm_idx = current_idx + llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路") + st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) + selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") + if selected_idx != current_idx: + agent.next_llm(selected_idx) + st.session_state.selected_llm_idx = selected_idx + st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") + st.rerun() + st.divider() + if st.button("重新注入System Prompt"): + agent.llmclient.last_tools = '' + st.toast("下次将重新注入System Prompt") + +with st.sidebar: render_sidebar() + + +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, '' + st.session_state.reply_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + st.session_state.current_prompt = prompt + + +def poll_agent_output(max_items=20): + q = st.session_state.display_queue + if q is None: + st.session_state.streaming = False + return False + done = False + for _ in range(max_items): + try: + item = q.get_nowait() + except queue.Empty: + break + if 'next' in item: st.session_state.partial_response = item['next'] + if 'done' in item: + st.session_state.partial_response = item['done'] + done = True + break + if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None + return done + + +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): + with st.chat_message(role): + if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) + 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)) + 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(): + st.markdown('', unsafe_allow_html=True) + if st.button("⏹️ 停止生成", type="primary"): + agent.abort(); st.session_state.stopping = True; st.toast("已发送停止信号"); st.rerun() + 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) + 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) + st.rerun() + diff --git a/start_streamlit.bat b/start_streamlit.bat new file mode 100644 index 000000000..933fab4c0 --- /dev/null +++ b/start_streamlit.bat @@ -0,0 +1,4 @@ +@echo off +cd /d "%~dp0" +.venv\Scripts\streamlit.exe run frontends/stapp.py --server.port 8501 +pause diff --git a/test_files/test.txt b/test_files/test.txt new file mode 100644 index 000000000..c5ea593df --- /dev/null +++ b/test_files/test.txt @@ -0,0 +1,9 @@ +This is a test file for GenericAgent file upload functionality. + +The file upload feature allows users to: +1. Upload multiple files (images, documents, etc.) +2. Preview files before sending +3. Delete individual files +4. Send files along with messages to the Agent + +The Agent can then use the file_read tool to access the uploaded files. From a6f83febffc2eeda41ba5a678c4243664a5ecbf2 Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Thu, 14 May 2026 14:46:15 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(ui):=20=E5=AF=B9=E9=BD=90=20Origin=20s?= =?UTF-8?q?tapp.py=20=E5=A4=96=E8=A7=82=E9=A3=8E=E6=A0=BC=EF=BC=8C?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E4=B8=8A=E4=BC=A0/=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 ANTHROPIC_CSS 精简为仅 :root CSS 变量定义,移除暖棕背景/侧边栏/按钮覆盖,页面恢复 Streamlit 默认主题 - 恢复汉堡菜单可见(移除 #MainMenu display:none) - 新增 st.title("🖥️ Cowork") 与 Origin stapp.py 保持一致 - 重写 render_sidebar():新增强行停止、重新注入工具、桌面宠物、自主行动区等按钮,LLM 选择框改为 collapsed 标签 - launch.pyw:切换至 stapp2.py,启动等待从 2s 升至 5s,idle_monitor 首次等待延长至 12s Co-Authored-By: Claude Sonnet 4.6 --- frontends/stapp2.py | 694 +++++--------------------------------------- launch.pyw | 8 +- 2 files changed, 74 insertions(+), 628 deletions(-) diff --git a/frontends/stapp2.py b/frontends/stapp2.py index 3e192412c..7fddcd56c 100644 --- a/frontends/stapp2.py +++ b/frontends/stapp2.py @@ -16,17 +16,27 @@ def _embed_html(html_str, height=1, width=None, scrolling=False): h = max(int(height), 1) if height is not None else 1 w = int(width) if (width is not None and int(width) > 0) else None return _cv1_html(html_str, width=w, height=h, scrolling=scrolling) -import time, json, re, threading, queue, base64 +import time, json, re, threading, queue, base64, subprocess +from urllib.request import urlopen +from urllib.parse import quote from datetime import datetime from io import BytesIO from agentmain import GeneraticAgent +script_dir = os.path.dirname(os.path.abspath(__file__)) st.set_page_config(page_title="Cowork", layout="wide") -# ─── Anthropic Light Theme CSS ─── +LANG = os.environ.get('GA_LANG', 'zh') +if LANG not in ('zh', 'en'): LANG = 'zh' +I18N = { + 'zh': {'force_stop': '强行停止任务', 'reinject_tools': '重新注入工具', 'desktop_pet': '🐱 桌面宠物'}, + 'en': {'force_stop': 'Force Stop', 'reinject_tools': 'Reinject Tools', 'desktop_pet': '🐱 Desktop Pet'}, +} +def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) + +# ─── CSS variables (needed for FILE_UPLOAD_CSS) ─── ANTHROPIC_CSS = """ """ @@ -1397,16 +796,14 @@ def init_session_state(): init_session_state() -# Inject Anthropic theme +# Inject CSS variables + upload/paste CSS st.markdown(ANTHROPIC_CSS, unsafe_allow_html=True) st.markdown(FILE_UPLOAD_CSS, unsafe_allow_html=True) st.markdown(PASTE_HIDDEN_INPUT_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) _embed_html(build_paste_listener_script(), height=0, width=0) -st.session_state.agent_name = 'Generic Agent' +st.title("🖥️ Cowork") + with st.chat_message("assistant"): st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) st.write("欢迎使用GenericAgent~") @@ -1414,21 +811,66 @@ def init_session_state(): @st.fragment def render_sidebar(): - llm_options, current_idx = agent.list_llms(), agent.llm_no - st.session_state.selected_llm_idx = current_idx + st.session_state.setdefault('autonomous_enabled', False) + llm_options = agent.list_llms() + current_idx = agent.llm_no llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} - st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路") + st.session_state.selected_llm_idx = current_idx + st.caption(f"LLM Core: {llm_labels.get(current_idx, str(current_idx))}") st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) - selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") + selected_idx = st.selectbox("LLM", [idx for idx, _, _ in llm_options], + index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), + format_func=llm_labels.get, label_visibility="collapsed", key="sidebar_llm_select") if selected_idx != current_idx: agent.next_llm(selected_idx) st.session_state.selected_llm_idx = selected_idx st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") st.rerun() - st.divider() - if st.button("重新注入System Prompt"): + if st.button(T('force_stop')): + agent.abort(); st.toast("Stop signal sent"); st.rerun() + if st.button(T('reinject_tools')): agent.llmclient.last_tools = '' - st.toast("下次将重新注入System Prompt") + try: + hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json') + with open(hist_path, 'r', encoding='utf-8') as f_: tool_hist = json.load(f_) + agent.llmclient.backend.history.extend(tool_hist) + st.toast("Tools injected") + except Exception as e: st.toast(f"Injected tools failed: {e}") + if st.button(T('desktop_pet')): + kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} + pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw') + if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw') + subprocess.Popen([sys.executable, pet_script], **kwargs) + def _pet_req(q): + def _do(): + try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2) + except Exception: pass + threading.Thread(target=_do, daemon=True).start() + agent._pet_req = _pet_req + if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} + def _pet_hook(ctx): + parts = [f"Turn {ctx.get('turn','?')}"] + if ctx.get('summary'): parts.append(ctx['summary']) + if ctx.get('exit_reason'): parts.append('DONE') + _pet_req(f'msg={quote(chr(10).join(parts))}') + if ctx.get('exit_reason'): _pet_req('state=idle') + agent._turn_end_hooks['pet'] = _pet_hook + st.toast("Desktop pet started") + if LANG == 'zh': + st.divider() + if st.button("开始空闲自主行动"): + st.session_state.last_reply_time = int(time.time()) - 1800 + st.toast("已将上次回复时间设为1800秒前"); st.rerun() + if st.session_state.autonomous_enabled: + if st.button("⏸️ 禁止自主行动"): + st.session_state.autonomous_enabled = False + st.toast("⏸️ 已禁止自主行动"); st.rerun() + st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") + else: + if st.button("▶️ 允许自主行动", type="primary"): + st.session_state.autonomous_enabled = True + st.toast("✅ 已允许自主行动"); st.rerun() + st.caption("🔴 自主行动已停止") with st.sidebar: render_sidebar() diff --git a/launch.pyw b/launch.pyw index 74658a4ed..7c587bdd1 100644 --- a/launch.pyw +++ b/launch.pyw @@ -18,7 +18,7 @@ def get_screen_width(): def start_streamlit(port): global proc - cmd = [sys.executable, "-m", "streamlit", "run", os.path.join(frontends_dir, "stapp.py"), "--server.port", str(port), "--server.address", "localhost", "--server.headless", "true", "--client.toolbarMode", "viewer"] + cmd = [sys.executable, "-m", "streamlit", "run", os.path.join(frontends_dir, "stapp2.py"), "--server.port", str(port), "--server.address", "localhost", "--server.headless", "true"] proc = subprocess.Popen(cmd) atexit.register(proc.kill) @@ -67,9 +67,13 @@ PASTE_HOOK_JS = """if (!window._pasteHooked) { window._pasteHooked = true; def idle_monitor(): last_trigger_time = 0 + # 等待窗口加载完毕再开始监控 + time.sleep(12) while True: time.sleep(5) try: + if not window or not window.evaluate_js('document.readyState'): + continue window.evaluate_js(PASTE_HOOK_JS) now = time.time() if now - last_trigger_time < 120: continue @@ -146,7 +150,7 @@ if __name__ == '__main__': screen_width = get_screen_width() x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING else: x_pos = 100 - time.sleep(2) + time.sleep(5) window = webview.create_window( title='GenericAgent', url=f'http://localhost:{port}', width=WINDOW_WIDTH, height=WINDOW_HEIGHT, x=x_pos, y=TOP_PADDING, From 1ef77a6a8d34e31ed18b9b79eef37f02220dfcd9 Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Thu, 14 May 2026 14:56:02 +0800 Subject: [PATCH 3/6] fix: upload row layout in narrow webview + launch.pyw robustness - FILE_UPLOAD_CSS: replace broken .input-row-container rule with :has() selector to pin upload column at 54px and fill remainder with chat input, fixing the upload button stacking on top of input in the 600px webview window - launch.pyw: initialize window=None at module level so idle_monitor guard short-circuits cleanly before webview.create_window() is called; add diagnostic prints + try/except around webview.start() to surface errors Co-Authored-By: Claude Sonnet 4.6 --- frontends/stapp2.py | 18 +++++++++++++----- launch.pyw | 11 ++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontends/stapp2.py b/frontends/stapp2.py index 7fddcd56c..2f164cd14 100644 --- a/frontends/stapp2.py +++ b/frontends/stapp2.py @@ -198,11 +198,19 @@ def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) background: #ef4444; } -/* 输入区域布局 */ -.input-row-container { - display: flex; - gap: 8px; - align-items: center; +/* 上传+输入行:禁止换行,上传列固定宽度,输入列填满剩余空间 */ +[data-testid="stHorizontalBlock"]:has([data-testid="stFileUploader"]) { + flex-wrap: nowrap !important; + align-items: flex-end !important; +} +[data-testid="stHorizontalBlock"]:has([data-testid="stFileUploader"]) > *:first-child { + flex: 0 0 54px !important; + min-width: 54px !important; + max-width: 54px !important; +} +[data-testid="stHorizontalBlock"]:has([data-testid="stFileUploader"]) > *:last-child { + flex: 1 1 0 !important; + min-width: 0 !important; } """ diff --git a/launch.pyw b/launch.pyw index 7c587bdd1..03a92cdf6 100644 --- a/launch.pyw +++ b/launch.pyw @@ -3,6 +3,7 @@ import webview, threading, subprocess, sys, time, os, ctypes, atexit, socket, ra WINDOW_WIDTH, WINDOW_HEIGHT, RIGHT_PADDING, TOP_PADDING = 600, 900, 0, 100 script_dir = os.path.dirname(os.path.abspath(__file__)) +window = None frontends_dir = os.path.join(script_dir, "frontends") def find_free_port(lo=18501, hi=18599): @@ -151,8 +152,16 @@ if __name__ == '__main__': x_pos = screen_width - WINDOW_WIDTH - RIGHT_PADDING else: x_pos = 100 time.sleep(5) + print(f'[Launch] Creating window at x={x_pos} y={TOP_PADDING} url=http://localhost:{port}') window = webview.create_window( title='GenericAgent', url=f'http://localhost:{port}', width=WINDOW_WIDTH, height=WINDOW_HEIGHT, x=x_pos, y=TOP_PADDING, resizable=True, text_select=True) - webview.start() + print(f'[Launch] Window object: {window}') + try: + webview.start() + print('[Launch] webview.start() returned normally — window was closed') + except Exception as e: + import traceback + print(f'[Launch] webview.start() raised: {e}') + traceback.print_exc() From e3459ef37e05d1f2bdbf9ef0e318bd41bb5a7244 Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Thu, 14 May 2026 23:31:42 +0800 Subject: [PATCH 4/6] =?UTF-8?q?stapp2=20=E4=BB=A3=E7=A0=81=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontends/stapp2.py | 1137 +++++++++++++++++++++++++++++++------------ 1 file changed, 817 insertions(+), 320 deletions(-) diff --git a/frontends/stapp2.py b/frontends/stapp2.py index 2f164cd14..eaac669d0 100644 --- a/frontends/stapp2.py +++ b/frontends/stapp2.py @@ -9,34 +9,39 @@ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) import streamlit as st -from streamlit.components.v1 import html as _cv1_html - -def _embed_html(html_str, height=1, width=None, scrolling=False): - """始终用 components.v1.html 嵌入 HTML 字符串(st.iframe 接受 URL,不能用于此处)""" - h = max(int(height), 1) if height is not None else 1 - w = int(width) if (width is not None and int(width) > 0) else None - return _cv1_html(html_str, width=w, height=h, scrolling=scrolling) -import time, json, re, threading, queue, base64, subprocess +try: + from streamlit import iframe as _st_iframe # 1.56+ + _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) +except (ImportError, AttributeError): + from streamlit.components.v1 import html as _embed_html # ≤1.55 +import time, json, re, threading, queue +from datetime import datetime +from agentmain import GeneraticAgent + +# ─── 新增:文件上传 / 粘贴 / 桌面宠物所需额外导入 ───────────────────────── +import base64, subprocess from urllib.request import urlopen from urllib.parse import quote -from datetime import datetime from io import BytesIO -from agentmain import GeneraticAgent -script_dir = os.path.dirname(os.path.abspath(__file__)) st.set_page_config(page_title="Cowork", layout="wide") +# ─── 新增:脚本所在目录(用于定位资源文件和桌面宠物脚本) ───────────────── +script_dir = os.path.dirname(os.path.abspath(__file__)) + +# ─── 新增:国际化支持(GA_LANG 环境变量,默认中文) ─────────────────────── LANG = os.environ.get('GA_LANG', 'zh') if LANG not in ('zh', 'en'): LANG = 'zh' I18N = { - 'zh': {'force_stop': '强行停止任务', 'reinject_tools': '重新注入工具', 'desktop_pet': '🐱 桌面宠物'}, - 'en': {'force_stop': 'Force Stop', 'reinject_tools': 'Reinject Tools', 'desktop_pet': '🐱 Desktop Pet'}, + 'zh': {'force_stop': '强行停止任务', 'reinject_tools': '重新注入System Prompt', 'desktop_pet': '🐱 桌面宠物'}, + 'en': {'force_stop': 'Force Stop', 'reinject_tools': 'Reinject Tools', 'desktop_pet': '🐱 Desktop Pet'}, } def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) -# ─── CSS variables (needed for FILE_UPLOAD_CSS) ─── +# ─── Anthropic Light Theme CSS(完整版,来自 old 版,保持不变) ────────── ANTHROPIC_CSS = """ """ +# ─── 新增:文件上传区域样式 ────────────────────────────────────────────── FILE_UPLOAD_CSS = """ """ +# ─── 新增:粘贴 / 删除信号输入框隐藏(覆盖 ANTHROPIC_CSS 中的定位规则) ── +# stTextInput 的 header 定位规则来自 ANTHROPIC_CSS,这里专门针对信号输入框覆盖 PASTE_HIDDEN_INPUT_CSS = """ """ +# ─── Selectbox 宽度修正 JS(来自 old 版,保持不变) ────────────────────── ANTHROPIC_SELECTBOX_SCRIPT = """
""" +# ─── 核心工具函数(来自 old 版,保持不变) ─────────────────────────────── + @st.cache_resource def init(): agent = GeneraticAgent() @@ -436,15 +978,105 @@ def build_dynamic_font_update_script(scale_percent: float) -> str: """ +def build_header_agent_badge_script() -> str: + return """ + +""" + +# ─── 新增:文件管理工具函数 ────────────────────────────────────────────── + def save_uploaded_file(file_dict: dict) -> str: - """保存上传的文件到 temp/uploaded/ 目录,返回绝对路径""" + """保存上传的文件到 temp/uploaded/ 目录,返回绝对路径。""" os.makedirs("temp/uploaded", exist_ok=True) - - # 文件名安全化 - safe_name = re.sub(r"[^A-Za-z0-9._\-\u4e00-\u9fa5]", "_", file_dict['name']) + 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']) @@ -455,27 +1087,19 @@ def save_uploaded_file(file_dict: dict) -> str: def generate_thumbnail(file_dict: dict, size=(80, 80)) -> str: - """ - 生成文件缩略图,返回 base64 data URI - - 图片:缩放后的 base64 图片 - - 其他:返回文件类型图标的 emoji 或 SVG - """ + """生成文件缩略图,返回 base64 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 "📷" # 降级为 emoji - - # 非图片文件返回图标 + return "📷" ext = os.path.splitext(file_dict['name'])[1].lower() icon_map = { '.pdf': '📄', '.txt': '📝', '.md': '📝', '.doc': '📄', '.docx': '📄', @@ -488,30 +1112,23 @@ def generate_thumbnail(file_dict: dict, size=(80, 80)) -> str: def build_prompt_with_files(prompt: str, files: list) -> tuple: - """ - 构建包含文件附件信息的提示 - 返回: (agent_prompt, display_prompt) - """ + """构建包含文件附件信息的提示,返回 (agent_prompt, display_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/'): - # 图片:提供路径 + base64(为未来 vision API 准备) 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)" # 截断避免过长 + f"\n data:{f['type']};base64,{b64[:100]}...(truncated)" ) elif f['name'].endswith(('.txt', '.md', '.py', '.json', '.log', '.csv', '.yaml', '.yml', '.js', '.ts', '.sql')): - # 文本文件:内联内容 try: text = f['content'].decode('utf-8', errors='replace') max_chars = 6000 @@ -524,18 +1141,15 @@ def build_prompt_with_files(prompt: str, files: list) -> tuple: 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) - file_names = ', '.join(f['name'] for f in files) - display_prompt = f"{prompt}\n\n📎 附件: {file_names}" - + display_prompt = f"{prompt}\n\n📎 附件: {', '.join(f['name'] for f in files)}" return agent_prompt, display_prompt def render_file_thumbnails(): - """渲染已上传文件的缩略图(_embed_html iframe,自带 deleteFile JS,无跨 iframe 依赖)""" + """渲染已上传文件的缩略图,删除按钮通过 JS 写入隐藏信号输入框触发 rerun。""" files = st.session_state.uploaded_files if not files: return @@ -543,7 +1157,6 @@ def render_file_thumbnails(): cards_html = [] for idx, f in enumerate(files): thumb = generate_thumbnail(f) - # 转义文件名,防止破坏 HTML 属性 safe_name = f['name'].replace('&', '&').replace('"', '"').replace("'", ''').replace('<', '<') name_display = safe_name[:18] @@ -577,7 +1190,6 @@ def render_file_thumbnails(): ) cards_html.append(card) - # 每行最多 8 张,计算 iframe 高度(6px 顶部留白 + 72px 图 + 3px + ~12px 文件名 + 4px 底部) n_rows = max(1, (len(files) + 7) // 8) iframe_height = n_rows * 100 + 10 @@ -613,21 +1225,16 @@ def render_file_thumbnails(): @st.dialog("文件预览", width="large") def preview_file_dialog(): - """文件预览模态对话框""" + """文件预览模态对话框。""" idx = st.session_state.preview_file_idx if idx is None or idx >= len(st.session_state.uploaded_files): return - f = st.session_state.uploaded_files[idx] - st.subheader(f"📎 {f['name']}") st.caption(f"类型: {f['type']} | 大小: {f['size']:,} bytes") - if f['type'].startswith('image/'): - # 图片预览 st.image(f['content'], use_container_width=True) elif f['name'].endswith(('.txt', '.md', '.py', '.json', '.log', '.csv', '.yaml', '.yml', '.js', '.ts', '.sql')): - # 文本文件预览 try: text = f['content'].decode('utf-8', errors='replace') st.code(text[:5000], language=None) @@ -636,113 +1243,15 @@ def preview_file_dialog(): except Exception as e: st.error(f"无法显示文件内容: {e}") else: - # 其他文件显示信息 st.info("此文件类型不支持预览") - st.json({ - "文件名": f['name'], - "类型": f['type'], - "大小": f"{f['size']:,} bytes", - }) - + st.json({"文件名": f['name'], "类型": f['type'], "大小": f"{f['size']:,} bytes"}) if st.button("关闭", use_container_width=True): st.session_state.preview_file_idx = None st.rerun() -def build_header_agent_badge_script() -> str: - return """ - -""" - - +# ─── 新增:粘贴监听脚本(捕获剪贴板图片,写入隐藏信号输入框触发 rerun) ── def build_paste_listener_script() -> str: - """注入粘贴监听 JS:捕获剪贴板图片并写入隐藏信号输入框触发 Streamlit rerun。""" return """ """ +# ─── 应用初始化 ────────────────────────────────────────────────────────── agent = init() def init_session_state(): for key, value in { + # 原有状态变量(来自 old 版) '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': [], - 'uploaded_files': [], # [{'name', 'type', 'size', 'content'}, ...] - 'file_uploader_key': 0, # 用于重置 file_uploader - 'preview_file_idx': None, # 当前预览的文件索引 + # 新增:文件上传状态 + 'uploaded_files': [], # [{'name', 'type', 'size', 'content'}, ...] + 'file_uploader_key': 0, # 用于重置 file_uploader widget + 'preview_file_idx': None, # 当前预览的文件索引 }.items(): st.session_state.setdefault(key, value) init_session_state() -# Inject CSS variables + upload/paste CSS +# 注入主题 CSS 和脚本(来自 old 版,全部恢复) 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) +# 新增:文件上传 & 粘贴支持的 CSS 和脚本 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) -st.title("🖥️ Cowork") - +st.session_state.agent_name = 'Generic Agent' with st.chat_message("assistant"): st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) st.write("欢迎使用GenericAgent~") +# ─── 侧边栏(基于 old 版,新增额外功能按钮) ───────────────────────────── @st.fragment def render_sidebar(): - st.session_state.setdefault('autonomous_enabled', False) - llm_options = agent.list_llms() - current_idx = agent.llm_no - llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + llm_options, current_idx = agent.list_llms(), agent.llm_no st.session_state.selected_llm_idx = current_idx - st.caption(f"LLM Core: {llm_labels.get(current_idx, str(current_idx))}") + llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} + st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路") st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) - selected_idx = st.selectbox("LLM", [idx for idx, _, _ in llm_options], - index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), - format_func=llm_labels.get, label_visibility="collapsed", key="sidebar_llm_select") + selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") if selected_idx != current_idx: agent.next_llm(selected_idx) st.session_state.selected_llm_idx = selected_idx st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") st.rerun() - if st.button(T('force_stop')): - agent.abort(); st.toast("Stop signal sent"); st.rerun() + st.divider() + # 重注入 System Prompt(来自 old 版,新增:额外加载 tool_usable_history.json) if st.button(T('reinject_tools')): agent.llmclient.last_tools = '' try: hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json') - with open(hist_path, 'r', encoding='utf-8') as f_: tool_hist = json.load(f_) + with open(hist_path, 'r', encoding='utf-8') as f_: + tool_hist = json.load(f_) agent.llmclient.backend.history.extend(tool_hist) - st.toast("Tools injected") - except Exception as e: st.toast(f"Injected tools failed: {e}") + except Exception: + pass + st.toast("下次将重新注入System Prompt") + # 新增:强行停止任务(不依赖 streaming 状态,随时可用) + if st.button(T('force_stop')): + agent.abort() + st.toast("Stop signal sent") + st.rerun() + # 新增:桌面宠物启动按钮 if st.button(T('desktop_pet')): kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw') - if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw') + if not os.path.exists(pet_script): + pet_script = os.path.join(script_dir, 'desktop_pet.pyw') subprocess.Popen([sys.executable, pet_script], **kwargs) + # 注入回调:agent 每个 turn 结束后通知桌面宠物显示状态 def _pet_req(q): def _do(): try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2) except Exception: pass threading.Thread(target=_do, daemon=True).start() agent._pet_req = _pet_req - if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {} + if not hasattr(agent, '_turn_end_hooks'): + agent._turn_end_hooks = {} def _pet_hook(ctx): - parts = [f"Turn {ctx.get('turn','?')}"] + parts = [f"Turn {ctx.get('turn', '?')}"] if ctx.get('summary'): parts.append(ctx['summary']) if ctx.get('exit_reason'): parts.append('DONE') _pet_req(f'msg={quote(chr(10).join(parts))}') if ctx.get('exit_reason'): _pet_req('state=idle') agent._turn_end_hooks['pet'] = _pet_hook st.toast("Desktop pet started") + # 新增:自主行动控制(仅中文模式显示) if LANG == 'zh': st.divider() if st.button("开始空闲自主行动"): st.session_state.last_reply_time = int(time.time()) - 1800 - st.toast("已将上次回复时间设为1800秒前"); st.rerun() + st.toast("已将上次回复时间设为1800秒前") + st.rerun() if st.session_state.autonomous_enabled: if st.button("⏸️ 禁止自主行动"): st.session_state.autonomous_enabled = False - st.toast("⏸️ 已禁止自主行动"); st.rerun() + st.toast("⏸️ 已禁止自主行动") + st.rerun() st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") else: if st.button("▶️ 允许自主行动", type="primary"): st.session_state.autonomous_enabled = True - st.toast("✅ 已允许自主行动"); st.rerun() + st.toast("✅ 已允许自主行动") + st.rerun() st.caption("🔴 自主行动已停止") with st.sidebar: render_sidebar() +# ─── Agent 任务控制(来自 old 版,保持不变) ───────────────────────────── + 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, '' @@ -913,11 +1442,14 @@ 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] + +# render_message:新增 intermediate 参数,用于将多轮推理中间段折叠为 expander +# old 版调用时不传此参数,行为与 old 版完全相同 def render_message(role, content, ts='', unsafe_allow_html=True, intermediate=False): with st.chat_message(role): - if ts: - st.markdown(f'
{ts}
', unsafe_allow_html=True) + if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) if intermediate: + # 中间推理轮次:用 expander 折叠,标签显示 "Turn N · 推理过程" m = re.match(r'^\*\*LLM Running \(Turn (\d+)\)', content.strip()) label = f"Turn {m.group(1)} · 推理过程" if m else "推理过程" with st.expander(label, expanded=False): @@ -925,10 +1457,13 @@ def render_message(role, content, ts='', unsafe_allow_html=True, intermediate=Fa else: st.markdown(content, unsafe_allow_html=unsafe_allow_html) + +# finish_streaming_message:新增 intermediate 标记,用于历史消息回放时折叠中间段 def finish_streaming_message(): reply_ts = st.session_state.reply_ts segments = _get_response_segments(st.session_state.partial_response) for i, seg in enumerate(segments): + # 非最后一段且格式匹配,才标记为中间推理轮次 is_intermediate = (i < len(segments) - 1) and bool( re.match(r'^\*\*LLM Running \(Turn \d+\)', seg.strip()) ) @@ -941,6 +1476,7 @@ def finish_streaming_message(): 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(): @@ -966,45 +1502,39 @@ def render_streaming_area(): else: time.sleep(0.2) st.rerun() + +# ─── 主流程 ────────────────────────────────────────────────────────────── + +# 历史消息渲染(来自 old 版,新增 intermediate 参数传递) 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), - ) + 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() -# ── 粘贴图片:隐藏信号输入框 + 处理逻辑 ────────────────────────────────── +# ─── 新增:粘贴图片处理(隐藏信号输入框 + 解码逻辑) ───────────────────── _paste_val = st.session_state.get("paste_image_signal", "") if _paste_val and _paste_val.startswith("data:image"): try: _header, _b64str = _paste_val.split(",", 1) - _mime = _header.split(":")[1].split(";")[0] # e.g. "image/png" - _ext = _mime.split("/")[1] # e.g. "png" + _mime = _header.split(":")[1].split(";")[0] + _ext = _mime.split("/")[1] _content = base64.b64decode(_b64str) if len(_content) <= 10 * 1024 * 1024: _fname = f"pasted_{datetime.now().strftime('%H%M%S%f')[:12]}.{_ext}" st.session_state.uploaded_files.append({ - 'name': _fname, - 'type': _mime, - 'size': len(_content), - 'content': _content, + 'name': _fname, 'type': _mime, + 'size': len(_content), 'content': _content, }) except Exception: pass st.session_state.paste_image_signal = "" st.rerun() -st.text_input( - "paste_signal", - value="", - key="paste_image_signal", - label_visibility="collapsed", - placeholder="__paste_image_signal__", -) +st.text_input("paste_signal", value="", key="paste_image_signal", + label_visibility="collapsed", placeholder="__paste_image_signal__") -# ── 删除文件信号 ────────────────────────────────────────────────────────── +# ─── 新增:删除文件信号处理 ─────────────────────────────────────────────── _del_val = st.session_state.get("delete_file_signal", "") if _del_val and _del_val.isdigit(): idx = int(_del_val) @@ -1013,81 +1543,48 @@ def render_streaming_area(): st.session_state.delete_file_signal = "" st.rerun() -st.text_input( - "delete_signal", - value="", - key="delete_file_signal", - label_visibility="collapsed", - placeholder="__delete_file_signal__", -) +st.text_input("delete_signal", value="", key="delete_file_signal", + label_visibility="collapsed", placeholder="__delete_file_signal__") -# 1. 渲染文件缩略图(如果有上传文件) +# ─── 新增:文件缩略图 + 输入区域布局 [文件上传按钮 | 聊天输入框] ────────── render_file_thumbnails() - -# 2. 输入区域布局: [文件上传按钮] [聊天输入框] col_upload, col_input = st.columns([0.08, 0.92]) with col_upload: - # 文件上传按钮(样式化为 + 按钮) uploaded_files = st.file_uploader( - "上传文件", - accept_multiple_files=True, - label_visibility="collapsed", + "上传文件", accept_multiple_files=True, label_visibility="collapsed", key=f"file_uploader_{st.session_state.file_uploader_key}", help="点击上传图片、文档等文件", ) - - # 处理新上传的文件 if uploaded_files: for uf in uploaded_files: - # 检查是否已存在 if any(f['name'] == uf.name for f in st.session_state.uploaded_files): continue - - # 读取文件内容 content = uf.read() - - # 文件大小限制: 10MB if len(content) > 10 * 1024 * 1024: st.warning(f"文件 {uf.name} 超过 10MB,已跳过") continue - - # 添加到状态 st.session_state.uploaded_files.append({ 'name': uf.name, 'type': uf.type if uf.type else 'application/octet-stream', - 'size': len(content), - 'content': content, + 'size': len(content), 'content': content, }) - - # 重置 uploader(避免重复处理) st.session_state.file_uploader_key += 1 st.rerun() with col_input: - # 聊天输入框 prompt = st.chat_input("请输入指令", disabled=st.session_state.streaming) - -# 4. 处理消息发送 +# 消息发送(来自 old 版,新增文件附件构建和发送后清空逻辑) if prompt: - # 构建包含文件的完整提示 files = st.session_state.uploaded_files agent_prompt, display_prompt = build_prompt_with_files(prompt, files) - - # 添加用户消息(显示简化版) st.session_state.messages.append({ - "role": "user", - "content": display_prompt, + "role": "user", "content": display_prompt, "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }) - - # 发送给 agent(完整版,包含文件内容) start_agent_task(agent_prompt) - - # 清空已上传文件 + # 新增:发送后清空已上传文件列表 st.session_state.uploaded_files.clear() st.session_state.file_uploader_key += 1 - st.rerun() - From a963160b0661bdb7609e25a16917d9f4062e539a Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Wed, 20 May 2026 14:59:50 +0800 Subject: [PATCH 5/6] chore: drop unrelated files from stapp2 PR (keep stapp2 + launch only) Co-authored-by: Cursor --- frontends/stapp2_old.py | 1049 --------------------------------------- start_streamlit.bat | 4 - test_files/test.txt | 9 - 3 files changed, 1062 deletions(-) delete mode 100644 frontends/stapp2_old.py delete mode 100644 start_streamlit.bat delete mode 100644 test_files/test.txt diff --git a/frontends/stapp2_old.py b/frontends/stapp2_old.py deleted file mode 100644 index 1d7968f5d..000000000 --- a/frontends/stapp2_old.py +++ /dev/null @@ -1,1049 +0,0 @@ -import os, sys -import html -if sys.stdout is None: sys.stdout = open(os.devnull, "w") -if sys.stderr is None: sys.stderr = open(os.devnull, "w") -try: sys.stdout.reconfigure(errors='replace') -except: pass -try: sys.stderr.reconfigure(errors='replace') -except: pass -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - -import streamlit as st -try: - from streamlit import iframe as _st_iframe # 1.56+ - _embed_html = lambda html, **kw: _st_iframe(html, **{k: max(v, 1) if isinstance(v, int) else v for k, v in kw.items()}) -except (ImportError, AttributeError): - from streamlit.components.v1 import html as _embed_html # ≤1.55 -import time, json, re, threading, queue -from datetime import datetime -from agentmain import GeneraticAgent - -st.set_page_config(page_title="Cowork", layout="wide") - -# ─── Anthropic Light Theme CSS ─── -ANTHROPIC_CSS = """ - -""" - -ANTHROPIC_SELECTBOX_SCRIPT = """ -
- -""" - -@st.cache_resource -def init(): - agent = GeneraticAgent() - if agent.llmclient is None: - st.error("⚠️ 未配置任何可用的 LLM 接口,请在 mykey.py 中添加 sider_cookie 或 oai_apikey+oai_apibase 等信息后重启。") - st.stop() - else: - threading.Thread(target=agent.run, daemon=True).start() - return agent - - -def build_dynamic_font_css(scale_percent: float) -> str: - root_percent = max(100.0, min(200.0, float(scale_percent))) - rem_scale = root_percent / 100.0 - return f""" - -""" - - -def build_dynamic_font_update_script(scale_percent: float) -> str: - css = json.dumps(build_dynamic_font_css(scale_percent)) - return f""" - -""" - - -def build_header_agent_badge_script() -> str: - return """ - -""" - -agent = init() - -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) - -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) - -st.session_state.agent_name = 'Generic Agent' -with st.chat_message("assistant"): - st.markdown(f'
{datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
', unsafe_allow_html=True) - st.write("欢迎使用GenericAgent~") - - -@st.fragment -def render_sidebar(): - llm_options, current_idx = agent.list_llms(), agent.llm_no - st.session_state.selected_llm_idx = current_idx - llm_labels = {idx: f"{idx}: {(name or '').strip()}" for idx, name, _ in llm_options} - st.caption(f"当前使用的LLM为:{current_idx}: {agent.get_llm_name()}", help="可在下方选择链路") - st.markdown(f'
{html.escape(max(llm_labels.values(), key=len, default=""))}
', unsafe_allow_html=True) - selected_idx = st.selectbox("选择链路:", [idx for idx, _, _ in llm_options], index=next((i for i, (idx, _, _) in enumerate(llm_options) if idx == current_idx), 0), format_func=llm_labels.get, key="sidebar_llm_select") - if selected_idx != current_idx: - agent.next_llm(selected_idx) - st.session_state.selected_llm_idx = selected_idx - st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") - st.rerun() - st.divider() - if st.button("重新注入System Prompt"): - agent.llmclient.last_tools = '' - st.toast("下次将重新注入System Prompt") - -with st.sidebar: render_sidebar() - - -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, '' - st.session_state.reply_ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - st.session_state.current_prompt = prompt - - -def poll_agent_output(max_items=20): - q = st.session_state.display_queue - if q is None: - st.session_state.streaming = False - return False - done = False - for _ in range(max_items): - try: - item = q.get_nowait() - except queue.Empty: - break - if 'next' in item: st.session_state.partial_response = item['next'] - if 'done' in item: - st.session_state.partial_response = item['done'] - done = True - break - if done: st.session_state.streaming = st.session_state.stopping = False; st.session_state.display_queue = None - return done - - -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): - with st.chat_message(role): - if ts: st.markdown(f'
{ts}
', unsafe_allow_html=True) - 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)) - 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(): - st.markdown('', unsafe_allow_html=True) - if st.button("⏹️ 停止生成", type="primary"): - agent.abort(); st.session_state.stopping = True; st.toast("已发送停止信号"); st.rerun() - 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) - 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) - st.rerun() - diff --git a/start_streamlit.bat b/start_streamlit.bat deleted file mode 100644 index 933fab4c0..000000000 --- a/start_streamlit.bat +++ /dev/null @@ -1,4 +0,0 @@ -@echo off -cd /d "%~dp0" -.venv\Scripts\streamlit.exe run frontends/stapp.py --server.port 8501 -pause diff --git a/test_files/test.txt b/test_files/test.txt deleted file mode 100644 index c5ea593df..000000000 --- a/test_files/test.txt +++ /dev/null @@ -1,9 +0,0 @@ -This is a test file for GenericAgent file upload functionality. - -The file upload feature allows users to: -1. Upload multiple files (images, documents, etc.) -2. Preview files before sending -3. Delete individual files -4. Send files along with messages to the Agent - -The Agent can then use the file_read tool to access the uploaded files. From dc12846ad1e322cabe4c3934e1ba9c501bf29ef1 Mon Sep 17 00:00:00 2001 From: Several_Miles Date: Wed, 20 May 2026 15:43:37 +0800 Subject: [PATCH 6/6] refactor(stapp2): split enhancements into stapp2_extensions Move attachment upload, paste-to-upload, sidebar controls, and compact uploader layout into stapp2_extensions.py so stapp2.py stays closer to upstream and PR review is easier. Co-authored-by: Cursor --- frontends/stapp2.py | 576 +++----------------------------- frontends/stapp2_extensions.py | 579 +++++++++++++++++++++++++++++++++ 2 files changed, 631 insertions(+), 524 deletions(-) create mode 100644 frontends/stapp2_extensions.py diff --git a/frontends/stapp2.py b/frontends/stapp2.py index eaac669d0..49fb0fff9 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,27 +29,11 @@ from datetime import datetime from agentmain import GeneraticAgent -# ─── 新增:文件上传 / 粘贴 / 桌面宠物所需额外导入 ───────────────────────── -import base64, subprocess -from urllib.request import urlopen -from urllib.parse import quote -from io import BytesIO +from frontends import stapp2_extensions as ext st.set_page_config(page_title="Cowork", layout="wide") -# ─── 新增:脚本所在目录(用于定位资源文件和桌面宠物脚本) ───────────────── -script_dir = os.path.dirname(os.path.abspath(__file__)) - -# ─── 新增:国际化支持(GA_LANG 环境变量,默认中文) ─────────────────────── -LANG = os.environ.get('GA_LANG', 'zh') -if LANG not in ('zh', 'en'): LANG = 'zh' -I18N = { - 'zh': {'force_stop': '强行停止任务', 'reinject_tools': '重新注入System Prompt', 'desktop_pet': '🐱 桌面宠物'}, - 'en': {'force_stop': 'Force Stop', 'reinject_tools': 'Reinject Tools', 'desktop_pet': '🐱 Desktop Pet'}, -} -def T(key): return I18N.get(LANG, I18N['zh']).get(key, key) - -# ─── Anthropic Light Theme CSS(完整版,来自 old 版,保持不变) ────────── +# ── Upstream: Anthropic light theme CSS | 上游:Anthropic 浅色主题 ───────── ANTHROPIC_CSS = """ """ -# ─── 新增:文件上传区域样式 ────────────────────────────────────────────── -FILE_UPLOAD_CSS = """ - -""" - -# ─── 新增:粘贴 / 删除信号输入框隐藏(覆盖 ANTHROPIC_CSS 中的定位规则) ── -# stTextInput 的 header 定位规则来自 ANTHROPIC_CSS,这里专门针对信号输入框覆盖 -PASTE_HIDDEN_INPUT_CSS = """ - -""" - -# ─── Selectbox 宽度修正 JS(来自 old 版,保持不变) ────────────────────── +# ── Upstream: sidebar selectbox width fix JS | 上游:侧栏下拉框宽度修正 ─── ANTHROPIC_SELECTBOX_SCRIPT = """
""" -# ─── 核心工具函数(来自 old 版,保持不变) ─────────────────────────────── +# ── Upstream: agent init & theme helpers | 上游:Agent 与主题工具 ───────── @st.cache_resource def init(): @@ -1069,262 +957,28 @@ def build_header_agent_badge_script() -> str: """ -# ─── 新增:文件管理工具函数 ────────────────────────────────────────────── - -def save_uploaded_file(file_dict: dict) -> str: - """保存上传的文件到 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: - """生成文件缩略图,返回 base64 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: - """构建包含文件附件信息的提示,返回 (agent_prompt, display_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(('.txt', '.md', '.py', '.json', '.log', '.csv', '.yaml', '.yml', '.js', '.ts', '.sql')): - 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(): - """渲染已上传文件的缩略图,删除按钮通过 JS 写入隐藏信号输入框触发 rerun。""" - 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'' - else: - inner = ( - f'
' - f'{thumb}
' - ) - - card = ( - f'
' - f'
' - f' {inner}' - f'
' - f'
{name_display}
' - f'
×
' - f'
' - ) - cards_html.append(card) - - n_rows = max(1, (len(files) + 7) // 8) - iframe_height = n_rows * 100 + 10 - - full_html = ( - '' - '
' - + ''.join(cards_html) - + '
' - '' - '' - ) - _embed_html(full_html, height=iframe_height) - - -@st.dialog("文件预览", width="large") -def preview_file_dialog(): - """文件预览模态对话框。""" - idx = st.session_state.preview_file_idx - if idx is None or idx >= len(st.session_state.uploaded_files): - return - f = st.session_state.uploaded_files[idx] - st.subheader(f"📎 {f['name']}") - st.caption(f"类型: {f['type']} | 大小: {f['size']:,} bytes") - if f['type'].startswith('image/'): - st.image(f['content'], use_container_width=True) - elif f['name'].endswith(('.txt', '.md', '.py', '.json', '.log', '.csv', '.yaml', '.yml', '.js', '.ts', '.sql')): - try: - text = f['content'].decode('utf-8', errors='replace') - st.code(text[:5000], language=None) - if len(text) > 5000: - st.info("内容已截断至前 5000 字符") - except Exception as e: - st.error(f"无法显示文件内容: {e}") - else: - st.info("此文件类型不支持预览") - st.json({"文件名": f['name'], "类型": f['type'], "大小": f"{f['size']:,} bytes"}) - if st.button("关闭", use_container_width=True): - st.session_state.preview_file_idx = None - st.rerun() - - -# ─── 新增:粘贴监听脚本(捕获剪贴板图片,写入隐藏信号输入框触发 rerun) ── -def build_paste_listener_script() -> str: - return """ - -""" - -# ─── 应用初始化 ────────────────────────────────────────────────────────── +# ── 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 { - # 原有状态变量(来自 old 版) - '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': [], - # 新增:文件上传状态 - 'uploaded_files': [], # [{'name', 'type', 'size', 'content'}, ...] - 'file_uploader_key': 0, # 用于重置 file_uploader widget - 'preview_file_idx': None, # 当前预览的文件索引 - }.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() -# 注入主题 CSS 和脚本(来自 old 版,全部恢复) 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) -# 新增:文件上传 & 粘贴支持的 CSS 和脚本 -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) +ext.inject_extension_assets() st.session_state.agent_name = 'Generic Agent' with st.chat_message("assistant"): @@ -1332,7 +986,7 @@ def init_session_state(): st.write("欢迎使用GenericAgent~") -# ─── 侧边栏(基于 old 版,新增额外功能按钮) ───────────────────────────── +# ── Upstream: sidebar (LLM switch) + fork extras | 上游侧栏 + fork 扩展 ── @st.fragment def render_sidebar(): llm_options, current_idx = agent.list_llms(), agent.llm_no @@ -1347,70 +1001,12 @@ def render_sidebar(): st.toast(f"已切换到备用链路:{llm_labels[selected_idx]}") st.rerun() st.divider() - # 重注入 System Prompt(来自 old 版,新增:额外加载 tool_usable_history.json) - if st.button(T('reinject_tools')): - agent.llmclient.last_tools = '' - try: - hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json') - with open(hist_path, 'r', encoding='utf-8') as f_: - tool_hist = json.load(f_) - agent.llmclient.backend.history.extend(tool_hist) - except Exception: - pass - st.toast("下次将重新注入System Prompt") - # 新增:强行停止任务(不依赖 streaming 状态,随时可用) - if st.button(T('force_stop')): - agent.abort() - st.toast("Stop signal sent") - st.rerun() - # 新增:桌面宠物启动按钮 - if st.button(T('desktop_pet')): - kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {} - pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw') - if not os.path.exists(pet_script): - pet_script = os.path.join(script_dir, 'desktop_pet.pyw') - subprocess.Popen([sys.executable, pet_script], **kwargs) - # 注入回调:agent 每个 turn 结束后通知桌面宠物显示状态 - def _pet_req(q): - def _do(): - try: urlopen(f'http://127.0.0.1:41983/?{q}', timeout=2) - except Exception: pass - threading.Thread(target=_do, daemon=True).start() - agent._pet_req = _pet_req - if not hasattr(agent, '_turn_end_hooks'): - agent._turn_end_hooks = {} - def _pet_hook(ctx): - parts = [f"Turn {ctx.get('turn', '?')}"] - if ctx.get('summary'): parts.append(ctx['summary']) - if ctx.get('exit_reason'): parts.append('DONE') - _pet_req(f'msg={quote(chr(10).join(parts))}') - if ctx.get('exit_reason'): _pet_req('state=idle') - agent._turn_end_hooks['pet'] = _pet_hook - st.toast("Desktop pet started") - # 新增:自主行动控制(仅中文模式显示) - if LANG == 'zh': - st.divider() - if st.button("开始空闲自主行动"): - st.session_state.last_reply_time = int(time.time()) - 1800 - st.toast("已将上次回复时间设为1800秒前") - st.rerun() - if st.session_state.autonomous_enabled: - if st.button("⏸️ 禁止自主行动"): - st.session_state.autonomous_enabled = False - st.toast("⏸️ 已禁止自主行动") - st.rerun() - st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") - else: - if st.button("▶️ 允许自主行动", type="primary"): - st.session_state.autonomous_enabled = True - st.toast("✅ 已允许自主行动") - st.rerun() - st.caption("🔴 自主行动已停止") + ext.render_sidebar_extras(agent) with st.sidebar: render_sidebar() -# ─── Agent 任务控制(来自 old 版,保持不变) ───────────────────────────── +# ── Upstream: agent task & chat loop | 上游:任务与对话循环 ─────────────── def start_agent_task(prompt): st.session_state.display_queue = agent.put_task(prompt, source="user") @@ -1443,35 +1039,38 @@ def _get_response_segments(text): return [p for p in re.split(r'(?=\*\*LLM Running \(Turn \d+\) \.\.\.\*\*)', text) if p.strip()] or [text] -# render_message:新增 intermediate 参数,用于将多轮推理中间段折叠为 expander -# old 版调用时不传此参数,行为与 old 版完全相同 +_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'
{ts}
', unsafe_allow_html=True) if intermediate: - # 中间推理轮次:用 expander 折叠,标签显示 "Turn N · 推理过程" - m = re.match(r'^\*\*LLM Running \(Turn (\d+)\)', content.strip()) - label = f"Turn {m.group(1)} · 推理过程" if m else "推理过程" - with st.expander(label, expanded=False): + 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) -# finish_streaming_message:新增 intermediate 标记,用于历史消息回放时折叠中间段 def finish_streaming_message(): reply_ts = st.session_state.reply_ts segments = _get_response_segments(st.session_state.partial_response) for i, seg in enumerate(segments): - # 非最后一段且格式匹配,才标记为中间推理轮次 - is_intermediate = (i < len(segments) - 1) and bool( - re.match(r'^\*\*LLM Running \(Turn \d+\)', seg.strip()) - ) st.session_state.messages.append({ "role": "assistant", "content": seg, "time": reply_ts, - "intermediate": is_intermediate, + "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 = '' @@ -1488,103 +1087,32 @@ def render_streaming_area(): segments = _get_response_segments(st.session_state.partial_response) for i, seg in enumerate(segments): is_last = (i == len(segments) - 1) - is_intermediate = (not is_last) and bool( - re.match(r'^\*\*LLM Running \(Turn \d+\)', seg.strip()) - ) render_message( "assistant", seg + ("" if not is_last else "▌"), ts=reply_ts, unsafe_allow_html=False, - intermediate=is_intermediate, + intermediate=_is_intermediate_turn_segment(seg, is_last=is_last), ) if poll_agent_output(): finish_streaming_message() else: time.sleep(0.2) st.rerun() -# ─── 主流程 ────────────────────────────────────────────────────────────── +# ── Main UI loop | 主界面 ───────────────────────────────────────────────── -# 历史消息渲染(来自 old 版,新增 intermediate 参数传递) 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() - -# ─── 新增:粘贴图片处理(隐藏信号输入框 + 解码逻辑) ───────────────────── -_paste_val = st.session_state.get("paste_image_signal", "") -if _paste_val and _paste_val.startswith("data:image"): - try: - _header, _b64str = _paste_val.split(",", 1) - _mime = _header.split(":")[1].split(";")[0] - _ext = _mime.split("/")[1] - _content = base64.b64decode(_b64str) - if len(_content) <= 10 * 1024 * 1024: - _fname = f"pasted_{datetime.now().strftime('%H%M%S%f')[:12]}.{_ext}" - st.session_state.uploaded_files.append({ - 'name': _fname, 'type': _mime, - 'size': len(_content), 'content': _content, - }) - except Exception: - pass - st.session_state.paste_image_signal = "" - st.rerun() - -st.text_input("paste_signal", value="", key="paste_image_signal", - label_visibility="collapsed", placeholder="__paste_image_signal__") +if st.session_state.streaming: + render_streaming_area() -# ─── 新增:删除文件信号处理 ─────────────────────────────────────────────── -_del_val = st.session_state.get("delete_file_signal", "") -if _del_val and _del_val.isdigit(): - idx = int(_del_val) - if 0 <= idx < len(st.session_state.uploaded_files): - st.session_state.uploaded_files.pop(idx) - st.session_state.delete_file_signal = "" +if ext.process_paste_signal() or ext.process_delete_signal(): st.rerun() -st.text_input("delete_signal", value="", key="delete_file_signal", - label_visibility="collapsed", placeholder="__delete_file_signal__") - -# ─── 新增:文件缩略图 + 输入区域布局 [文件上传按钮 | 聊天输入框] ────────── -render_file_thumbnails() -col_upload, col_input = st.columns([0.08, 0.92]) - -with col_upload: - uploaded_files = st.file_uploader( - "上传文件", accept_multiple_files=True, label_visibility="collapsed", - key=f"file_uploader_{st.session_state.file_uploader_key}", - help="点击上传图片、文档等文件", - ) - if uploaded_files: - for uf in uploaded_files: - if any(f['name'] == uf.name for f in st.session_state.uploaded_files): - continue - content = uf.read() - if len(content) > 10 * 1024 * 1024: - st.warning(f"文件 {uf.name} 超过 10MB,已跳过") - continue - st.session_state.uploaded_files.append({ - 'name': uf.name, - 'type': uf.type if uf.type else 'application/octet-stream', - 'size': len(content), 'content': content, - }) - st.session_state.file_uploader_key += 1 - st.rerun() - -with col_input: - prompt = st.chat_input("请输入指令", disabled=st.session_state.streaming) +ext.render_signal_inputs() +prompt = ext.render_attachment_input_row(streaming=st.session_state.streaming) -# 消息发送(来自 old 版,新增文件附件构建和发送后清空逻辑) if prompt: - files = st.session_state.uploaded_files - agent_prompt, display_prompt = build_prompt_with_files(prompt, files) - st.session_state.messages.append({ - "role": "user", "content": display_prompt, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - start_agent_task(agent_prompt) - # 新增:发送后清空已上传文件列表 - st.session_state.uploaded_files.clear() - st.session_state.file_uploader_key += 1 - st.rerun() + 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 000000000..617b08513 --- /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'' + else: + inner = ( + f'
{thumb}
' + ) + card = ( + f'
' + f'
{inner}
' + f'
{name_display}
' + f'
×
' + ) + cards_html.append(card) + + n_rows = max(1, (len(files) + 7) // 8) + iframe_height = n_rows * 100 + 10 + full_html = ( + "" + '
' + + "".join(cards_html) + + "
" + ) + _embed_html(full_html, height=iframe_height) + + +@st.dialog("文件预览", width="large") +def preview_file_dialog() -> None: + """File preview modal | 文件预览对话框""" + idx = st.session_state.preview_file_idx + if idx is None or idx >= len(st.session_state.uploaded_files): + return + f = st.session_state.uploaded_files[idx] + st.subheader(f"📎 {f['name']}") + st.caption(f"类型: {f['type']} | 大小: {f['size']:,} bytes") + if f["type"].startswith("image/"): + st.image(f["content"], use_container_width=True) + elif f["name"].endswith(TEXT_FILE_SUFFIXES): + try: + text = f["content"].decode("utf-8", errors="replace") + st.code(text[:5000], language=None) + if len(text) > 5000: + st.info("内容已截断至前 5000 字符") + except Exception as e: + st.error(f"无法显示文件内容: {e}") + else: + st.info("此文件类型不支持预览") + st.json({"文件名": f["name"], "类型": f["type"], "大小": f"{f['size']:,} bytes"}) + if st.button("关闭", use_container_width=True): + st.session_state.preview_file_idx = None + st.rerun() + + +def process_paste_signal() -> bool: + """Decode clipboard image from hidden input; return True if rerun needed""" + val = st.session_state.get("paste_image_signal", "") + if not val or not val.startswith("data:image"): + return False + try: + header, b64str = val.split(",", 1) + mime = header.split(":")[1].split(";")[0] + ext = mime.split("/")[1] + content = base64.b64decode(b64str) + if len(content) <= MAX_ATTACHMENT_BYTES: + fname = f"pasted_{datetime.now().strftime('%H%M%S%f')[:12]}.{ext}" + st.session_state.uploaded_files.append({ + "name": fname, "type": mime, "size": len(content), "content": content, + }) + except Exception: + pass + st.session_state.paste_image_signal = "" + return True + + +def process_delete_signal() -> bool: + """Remove attachment by index from hidden input; return True if rerun needed""" + val = st.session_state.get("delete_file_signal", "") + if not val or not val.isdigit(): + return False + idx = int(val) + if 0 <= idx < len(st.session_state.uploaded_files): + st.session_state.uploaded_files.pop(idx) + st.session_state.delete_file_signal = "" + return True + + +def render_signal_inputs() -> None: + """Hidden Streamlit inputs for JS bridges | 供 JS 桥接用的隐藏输入框""" + st.text_input( + "paste_signal", value="", key="paste_image_signal", + label_visibility="collapsed", placeholder=PLACEHOLDER_PASTE, + ) + st.text_input( + "delete_signal", value="", key="delete_file_signal", + label_visibility="collapsed", placeholder=PLACEHOLDER_DELETE, + ) + + +def render_attachment_input_row(*, streaming: bool): + """ + Layout: thumbnails + [file_uploader | chat_input] + 布局:缩略图 + [上传按钮 | 聊天输入框] + Returns prompt string or None. + """ + render_file_thumbnails() + col_upload, col_input = st.columns([0.08, 0.92]) + with col_upload: + uploaded = st.file_uploader( + "上传文件", accept_multiple_files=True, label_visibility="collapsed", + key=f"file_uploader_{st.session_state.file_uploader_key}", + help="点击上传图片、文档等文件", + ) + if uploaded: + for uf in uploaded: + if any(f["name"] == uf.name for f in st.session_state.uploaded_files): + continue + content = uf.read() + if len(content) > MAX_ATTACHMENT_BYTES: + st.warning(f"文件 {uf.name} 超过 10MB,已跳过") + continue + st.session_state.uploaded_files.append({ + "name": uf.name, + "type": uf.type or "application/octet-stream", + "size": len(content), + "content": content, + }) + st.session_state.file_uploader_key += 1 + st.rerun() + with col_input: + return st.chat_input("请输入指令", disabled=streaming) + + +def handle_user_submit(prompt: str, *, start_task) -> None: + """Append message, send to agent with attachments, clear uploads | 发送并清空附件""" + files = st.session_state.uploaded_files + agent_prompt, display_prompt = build_prompt_with_files(prompt, files) + st.session_state.messages.append({ + "role": "user", + "content": display_prompt, + "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }) + start_task(agent_prompt) + st.session_state.uploaded_files.clear() + st.session_state.file_uploader_key += 1 + st.rerun() + + +# ── Module D: sidebar extras | 侧边栏扩展 ───────────────────────────────── + +def render_sidebar_extras(agent) -> None: + """Extra sidebar controls | 扩展侧栏控件(强停、宠物、重注入等)""" + lang = get_lang() + if st.button(t("reinject_tools")): + agent.llmclient.last_tools = "" + try: + hist_path = os.path.join(SCRIPT_DIR, "..", "assets", "tool_usable_history.json") + with open(hist_path, encoding="utf-8") as f: + agent.llmclient.backend.history.extend(json.load(f)) + except Exception: + pass + st.toast("下次将重新注入System Prompt") + + if st.button(t("force_stop")): + agent.abort() + st.toast("Stop signal sent") + st.rerun() + + if st.button(t("desktop_pet")): + kwargs = {"creationflags": 0x08} if sys.platform == "win32" else {} + pet_script = os.path.join(SCRIPT_DIR, "desktop_pet_v2.pyw") + if not os.path.exists(pet_script): + pet_script = os.path.join(SCRIPT_DIR, "desktop_pet.pyw") + subprocess.Popen([sys.executable, pet_script], **kwargs) + + def _pet_req(q: str) -> None: + def _do() -> None: + try: + urlopen(f"http://127.0.0.1:41983/?{q}", timeout=2) + except Exception: + pass + threading.Thread(target=_do, daemon=True).start() + + agent._pet_req = _pet_req + if not hasattr(agent, "_turn_end_hooks"): + agent._turn_end_hooks = {} + + def _pet_hook(ctx: dict) -> None: + parts = [f"Turn {ctx.get('turn', '?')}"] + if ctx.get("summary"): + parts.append(ctx["summary"]) + if ctx.get("exit_reason"): + parts.append("DONE") + _pet_req(f"msg={quote(chr(10).join(parts))}") + if ctx.get("exit_reason"): + _pet_req("state=idle") + + agent._turn_end_hooks["pet"] = _pet_hook + st.toast("Desktop pet started") + + if lang == "zh": + st.divider() + if st.button("开始空闲自主行动"): + st.session_state.last_reply_time = int(time.time()) - 1800 + st.toast("已将上次回复时间设为1800秒前") + st.rerun() + if st.session_state.autonomous_enabled: + if st.button("⏸️ 禁止自主行动"): + st.session_state.autonomous_enabled = False + st.toast("⏸️ 已禁止自主行动") + st.rerun() + st.caption("🟢 自主行动运行中,会在你离开它30分钟后自动进行") + else: + if st.button("▶️ 允许自主行动", type="primary"): + st.session_state.autonomous_enabled = True + st.toast("✅ 已允许自主行动") + st.rerun() + st.caption("🔴 自主行动已停止")