refactor: turn_end_callback + desktop pet + keychain
- agent_loop: next_prompt_patcher -> turn_end_callback with full context - agent_loop: exit logic unified (break + callback), no early return - ga: summary extraction moved from tool_after_callback to turn_end_callback - ga: _turn_end_hooks support for external subscribers - stapp: desktop pet button with HTTP status push - keychain: XOR-masked secret storage with SecretStr - gitignore: whitelist keychain.py
This commit is contained in:
93
frontends/desktop_pet.pyw
Normal file
93
frontends/desktop_pet.pyw
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Desktop Pet with HTTP Toast — ~90 lines"""
|
||||
import tkinter as tk, threading, random, os, sys
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
PORT = 51983
|
||||
GIF = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), 'pet.gif')
|
||||
|
||||
class Pet:
|
||||
def __init__(self):
|
||||
self.root = tk.Tk()
|
||||
self.root.overrideredirect(True)
|
||||
self.root.wm_attributes('-topmost', True)
|
||||
self.root.wm_attributes('-transparentcolor', '#01FF01')
|
||||
self.root.config(bg='#01FF01')
|
||||
self.root.after(50, lambda: self.root.geometry('+300+500'))
|
||||
# load GIF frames
|
||||
self.frames, i = [], 0
|
||||
while True:
|
||||
try: self.frames.append(tk.PhotoImage(file=GIF, format=f'gif -index {i}')); i += 1
|
||||
except: break
|
||||
if not self.frames: raise FileNotFoundError(f'No GIF: {GIF}')
|
||||
self.idx = 0
|
||||
self.label = tk.Label(self.root, image=self.frames[0], bg='#01FF01', bd=0)
|
||||
self.label.pack()
|
||||
# drag
|
||||
self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
|
||||
self.label.bind('<B1-Motion>', self._drag)
|
||||
self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))
|
||||
# start loops
|
||||
self._animate()
|
||||
self._wander()
|
||||
self._start_server()
|
||||
self.root.mainloop()
|
||||
|
||||
def _drag(self, e):
|
||||
x, y = self.root.winfo_x() + e.x - self._d[0], self.root.winfo_y() + e.y - self._d[1]
|
||||
self.root.geometry(f'+{x}+{y}')
|
||||
|
||||
def _animate(self):
|
||||
self.idx = (self.idx + 1) % len(self.frames)
|
||||
self.label.config(image=self.frames[self.idx])
|
||||
self.root.after(150, self._animate)
|
||||
|
||||
def _wander(self):
|
||||
if random.random() < 0.25:
|
||||
x = self.root.winfo_x() + random.randint(-15, 15)
|
||||
y = self.root.winfo_y() + random.randint(-5, 5)
|
||||
self.root.geometry(f'+{x}+{y}')
|
||||
self.root.after(4000, self._wander)
|
||||
|
||||
def show_toast(self, msg):
|
||||
"""Show a speech bubble near the pet that auto-dismisses."""
|
||||
tw = tk.Toplevel(self.root)
|
||||
tw.overrideredirect(True)
|
||||
tw.wm_attributes('-topmost', True)
|
||||
tw.config(bg='#FFFDE7')
|
||||
px, py = self.root.winfo_x(), self.root.winfo_y()
|
||||
tw.geometry(f'+{px + 30}+{py - 50}')
|
||||
# bubble content
|
||||
f = tk.Frame(tw, bg='#FFFDE7', highlightbackground='#888', highlightthickness=1, padx=8, pady=4)
|
||||
f.pack()
|
||||
tk.Label(f, text=msg, bg='#FFFDE7', fg='#333', font=('Segoe UI', 10), wraplength=220, justify='left').pack()
|
||||
# auto dismiss
|
||||
tw.after(3000, tw.destroy)
|
||||
|
||||
def _start_server(self):
|
||||
pet = self
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
msg = qs.get('msg', [''])[0]
|
||||
if msg:
|
||||
pet.root.after(0, pet.show_toast, msg)
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
|
||||
else:
|
||||
self.send_response(400); self.end_headers(); self.wfile.write(b'?msg=xxx')
|
||||
def do_POST(self):
|
||||
body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode()
|
||||
if body:
|
||||
pet.root.after(0, pet.show_toast, body)
|
||||
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
|
||||
else:
|
||||
self.send_response(400); self.end_headers(); self.wfile.write(b'empty body')
|
||||
def log_message(self, *a): pass
|
||||
HTTPServer.allow_reuse_address = False
|
||||
srv = HTTPServer(('127.0.0.1', PORT), H)
|
||||
t = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||
t.start()
|
||||
print(f'Toast server: http://127.0.0.1:{PORT}/?msg=hello')
|
||||
|
||||
if __name__ == '__main__':
|
||||
Pet()
|
||||
BIN
frontends/pet.gif
Normal file
BIN
frontends/pet.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +1,4 @@
|
||||
import os, sys
|
||||
import os, sys, subprocess
|
||||
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')
|
||||
@@ -47,6 +47,24 @@ def render_sidebar():
|
||||
if st.button("重新注入System Prompt"):
|
||||
agent.llmclient.last_tools = ''
|
||||
st.toast("下次将重新注入System Prompt")
|
||||
if st.button("🐱 桌面宠物"):
|
||||
kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}
|
||||
subprocess.Popen([sys.executable, os.path.join(os.path.dirname(__file__), 'desktop_pet.pyw')], **kwargs)
|
||||
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('✅ 任务已完成')
|
||||
msg = '\n'.join(parts)
|
||||
def send():
|
||||
try:
|
||||
from urllib.request import urlopen
|
||||
from urllib.parse import quote
|
||||
urlopen(f'http://127.0.0.1:51983/?msg={quote(msg)}', timeout=2)
|
||||
except: pass
|
||||
threading.Thread(target=send, daemon=True).start()
|
||||
agent._turn_end_hooks['pet'] = _pet_hook
|
||||
st.toast("桌面宠物已启动")
|
||||
|
||||
st.divider()
|
||||
if st.button("开始空闲自主行动"):
|
||||
|
||||
Reference in New Issue
Block a user