diff --git a/frontends/DESKTOP_PET_README.md b/frontends/DESKTOP_PET_README.md new file mode 100644 index 0000000..1ee8798 --- /dev/null +++ b/frontends/DESKTOP_PET_README.md @@ -0,0 +1,175 @@ +# Desktop Pet Skin System + +## 快速开始 + +运行桌面宠物: +```bash +python3 desktop_pet_v2.pyw +``` + +## 功能特性 + +### 1. 多皮肤支持 +- 自动发现 `skins/` 目录下的所有皮肤 +- 右键菜单切换皮肤 +- 支持 sprite sheet 和 GIF 两种格式 + +### 2. 多动画状态 +- **idle** - 待机动画 +- **walk** - 行走动画 +- **run** - 跑步动画 +- **sprint** - 冲刺动画 + +右键菜单可切换动画状态 + +### 3. 交互功能 +- **单击** - 拖动宠物 +- **双击** - 关闭程序 +- **右键** - 打开菜单(切换皮肤/动画) + +### 4. HTTP 远程控制 +```bash +# 显示消息 +curl "http://127.0.0.1:51983/?msg=Hello" + +# 切换动画状态 +curl "http://127.0.0.1:51983/?state=run" + +# POST 消息 +curl -X POST -d "任务完成" http://127.0.0.1:51983/ +``` + +## 添加新皮肤 + +### 目录结构 +``` +skins/ +└── your-skin-name/ + ├── skin.json # 配置文件(必需) + ├── idle.png # 动画资源 + ├── walk.png + ├── run.png + └── sprint.png +``` + +### skin.json 配置示例 + +#### Sprite Sheet 格式(推荐) +```json +{ + "name": "My Pet", + "version": "1.0.0", + "author": "Your Name", + "description": "描述", + "format": "sprite", + "animations": { + "idle": { + "file": "idle.png", + "loop": true, + "sprite": { + "frameWidth": 44, + "frameHeight": 31, + "frameCount": 6, + "columns": 6, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "walk.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 8, + "startFrame": 0 + } + } + } +} +``` + +#### GIF 格式 +```json +{ + "name": "My Pet", + "format": "gif", + "animations": { + "idle": { + "file": "idle.gif", + "loop": true + }, + "walk": { + "file": "walk.gif", + "loop": true + } + } +} +``` + +### 配置说明 + +- **frameWidth/frameHeight**: 单帧尺寸(像素) +- **frameCount**: 帧数 +- **columns**: sprite sheet 的列数 +- **fps**: 播放帧率 +- **startFrame**: 起始帧索引(从 0 开始) + +### Sprite Sheet 布局 + +``` ++-------+-------+-------+-------+ +| 帧0 | 帧1 | 帧2 | 帧3 | ← 第一行 ++-------+-------+-------+-------+ +| 帧4 | 帧5 | 帧6 | 帧7 | ← 第二行 ++-------+-------+-------+-------+ +``` + +如果 `columns=4, startFrame=2, frameCount=3`,则读取:帧2, 帧3, 帧4 + +## 已包含的皮肤 + +1. **Glube** - 像素风小怪兽(多文件 sprite) +2. **Vita** - 像素风小恐龙(单文件 sprite) +3. **Doux** - 像素风小恐龙(单文件 sprite) + +## 从 ai-bubu 导入更多皮肤 + +ai-bubu 项目包含更多皮肤资源,可以直接复制: + +```bash +# 复制皮肤 +cp -r ai-bubu-main/packages/app/public/skins/boy frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/dinosaur frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/line frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/mort frontends/skins/ +cp -r ai-bubu-main/packages/app/public/skins/tard frontends/skins/ +``` + +## 与 stapp.py 集成 + +在 `stapp.py` 中点击"🐱 桌面宠物"按钮会自动启动桌面宠物,并在每个 turn 结束时发送通知。 + +## 故障排查 + +### 皮肤不显示 +1. 检查 `skin.json` 格式是否正确 +2. 确认图片文件存在 +3. 检查 sprite 配置参数是否匹配图片尺寸 + +### 动画不流畅 +- 调整 `fps` 参数 +- 检查帧数是否正确 + +### 透明背景问题 +- 确保 PNG 文件包含 alpha 通道 +- 使用 RGBA 模式的图片 + +## 技术细节 + +- 基于 Tkinter + PIL/Pillow +- 支持透明背景(#01FF01 色键) +- 窗口置顶、无边框 +- HTTP 服务器端口:51983 diff --git a/frontends/desktop_pet_v2.pyw b/frontends/desktop_pet_v2.pyw new file mode 100644 index 0000000..caf22f2 --- /dev/null +++ b/frontends/desktop_pet_v2.pyw @@ -0,0 +1,818 @@ +"""Desktop Pet with Skin System — Cross-platform with True Transparency""" +import os +import sys +import json +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import urlparse, parse_qs +from PIL import Image, ImageDraw, ImageFont, ImageOps +import io + +PORT = 51983 +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +PROJECT_DIR = os.path.dirname(SCRIPT_DIR) +SKINS_DIR = os.path.join(SCRIPT_DIR, 'skins') + +class SkinLoader: + """Load and parse skin configuration""" + @staticmethod + def load_skin(skin_path): + """Load skin.json and return skin config""" + config_file = os.path.join(skin_path, 'skin.json') + if not os.path.exists(config_file): + raise FileNotFoundError(f"skin.json not found in {skin_path}") + + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + if 'animations' not in config: + raise ValueError("skin.json must contain 'animations' field") + + config['path'] = skin_path + return config + + @staticmethod + def list_skins(): + """List all available skins""" + if not os.path.exists(SKINS_DIR): + return [] + + skins = [] + for item in os.listdir(SKINS_DIR): + skin_path = os.path.join(SKINS_DIR, item) + if os.path.isdir(skin_path): + config_file = os.path.join(skin_path, 'skin.json') + if os.path.exists(config_file): + skins.append(item) + return skins + +class AnimationLoader: + """Load animation frames from sprite sheet""" + @staticmethod + def load_sprite_frames(skin_path, anim_config): + """Load frames from sprite sheet""" + file_path = os.path.join(skin_path, anim_config['file']) + sprite_config = anim_config['sprite'] + + img = Image.open(file_path) + frames = [] + + frame_width = sprite_config['frameWidth'] + frame_height = sprite_config['frameHeight'] + frame_count = sprite_config['frameCount'] + columns = sprite_config['columns'] + start_frame = sprite_config.get('startFrame', 0) + + for i in range(frame_count): + frame_idx = start_frame + i + row = frame_idx // columns + col = frame_idx % columns + + x = col * frame_width + y = row * frame_height + + frame = img.crop((x, y, x + frame_width, y + frame_height)) + frames.append(frame) + + return frames + + +def _find_bubble_asset(): + """Find user-provided bubble asset in project root.""" + candidates = [ + os.path.join(PROJECT_DIR, '聊天气泡.png'), + os.path.join(PROJECT_DIR, 'bubble.png'), + ] + for path in candidates: + if os.path.exists(path): + return path + return None + + +def _load_default_font(size): + """Load a usable font for bubble text.""" + font_candidates = [ + '/System/Library/Fonts/Supplemental/Arial Unicode.ttf', + '/System/Library/Fonts/PingFang.ttc', + '/System/Library/Fonts/STHeiti Light.ttc', + 'C:/Windows/Fonts/msyh.ttc', + 'C:/Windows/Fonts/simhei.ttf', + 'C:/Windows/Fonts/arial.ttf', + ] + for font_path in font_candidates: + if os.path.exists(font_path): + try: + return ImageFont.truetype(font_path, size=size) + except Exception: + pass + return ImageFont.load_default() + + +def _wrap_text_for_width(draw, text, font, max_width): + """Wrap text to fit inside max_width.""" + text = (text or '').strip() + if not text: + return [''] + + paragraphs = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') + lines = [] + + for paragraph in paragraphs: + if not paragraph: + lines.append('') + continue + + current = '' + for ch in paragraph: + candidate = current + ch + bbox = draw.textbbox((0, 0), candidate, font=font) + width = bbox[2] - bbox[0] + if current and width > max_width: + lines.append(current) + current = ch + else: + current = candidate + if current: + lines.append(current) + + return lines or [''] + + +def build_bubble_image(message, max_width=220): + """Build a PIL image for the toast bubble using the user asset when available.""" + message = (message or '').strip() + bubble_path = _find_bubble_asset() + + if bubble_path and os.path.exists(bubble_path): + bubble = Image.open(bubble_path).convert('RGBA') + else: + bubble = Image.new('RGBA', (256, 128), (255, 255, 255, 0)) + draw = ImageDraw.Draw(bubble) + draw.rounded_rectangle((8, 8, 247, 87), radius=12, fill=(255, 255, 255, 255), outline=(0, 0, 0, 255), width=3) + draw.polygon([(48, 87), (72, 87), (56, 112)], fill=(255, 255, 255, 255), outline=(0, 0, 0, 255)) + + bubble = ImageOps.contain(bubble, (max_width, max(64, int(max_width * bubble.height / bubble.width))), Image.NEAREST) + + font_size = max(12, bubble.height // 7) + font = _load_default_font(font_size) + draw = ImageDraw.Draw(bubble) + + pad_left = max(10, bubble.width // 18) + pad_right = max(10, bubble.width // 18) + max(6, bubble.width // 14) + pad_top = max(8, bubble.height // 10) + pad_bottom = max(18, bubble.height // 4) + text_area_width = max(40, bubble.width - pad_left - pad_right) + + lines = _wrap_text_for_width(draw, message, font, text_area_width) + ascent, descent = font.getmetrics() if hasattr(font, 'getmetrics') else (font_size, font_size // 4) + line_height = max(font_size, ascent + descent) + max_lines = max(1, (bubble.height - pad_top - pad_bottom) // line_height) + if len(lines) > max_lines: + lines = lines[:max_lines] + if lines: + last = lines[-1] + while last and draw.textbbox((0, 0), last + '…', font=font)[2] > text_area_width: + last = last[:-1] + lines[-1] = (last + '…') if last else '…' + + total_text_height = len(lines) * line_height + y = pad_top + max(0, (bubble.height - pad_top - pad_bottom - total_text_height) // 2) + + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + text_width = bbox[2] - bbox[0] + x = pad_left + (text_area_width - text_width) / 2 + draw.text((x, y), line, font=font, fill=(32, 32, 32, 255)) + y += line_height + + alpha = bubble.getchannel('A') + bbox = alpha.getbbox() + if bbox: + bubble = bubble.crop(bbox) + + width, height = bubble.size + alpha = bubble.getchannel('A') + bottom_y = height - 1 + tail_x = width // 2 + for y in range(height - 1, -1, -1): + xs = [x for x in range(width) if alpha.getpixel((x, y)) > 0] + if xs: + bottom_y = y + tail_x = xs[len(xs) // 2] + break + + return { + 'image': bubble, + 'size': bubble.size, + 'tail_tip': (tail_x, bottom_y), + } + +# ============================================================================ +# macOS Implementation - Pure Cocoa with True Transparency +# ============================================================================ +if sys.platform == 'darwin': + from Cocoa import ( + NSApplication, NSWindow, NSImageView, NSImage, NSData, NSTimer, + NSMenu, NSMenuItem, NSApp, NSFloatingWindowLevel, NSColor, + NSBackingStoreBuffered, NSWindowStyleMaskBorderless, + NSApplicationActivationPolicyAccessory + ) + from Foundation import NSMakeRect, NSMakePoint, NSMakeSize + from PyObjCTools import AppHelper + import objc + + class MacPet: + def __init__(self, skin_name=None): + self.app = NSApplication.sharedApplication() + self.app.setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + # Load skin + self.load_skin(skin_name) + + # Get screen size + from AppKit import NSScreen, NSWindowCollectionBehaviorCanJoinAllSpaces, NSWindowCollectionBehaviorStationary + screen = NSScreen.mainScreen() + screen_frame = screen.frame() + screen_width = screen_frame.size.width + screen_height = screen_frame.size.height + + # Position at right side + x_pos = screen_width - 200 + y_pos = 300 + + # Create transparent window + self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + NSMakeRect(x_pos, y_pos, self.display_width, self.display_height), + NSWindowStyleMaskBorderless, + NSBackingStoreBuffered, + False + ) + + self.window.setOpaque_(False) + self.window.setBackgroundColor_(NSColor.clearColor()) + self.window.setLevel_(NSFloatingWindowLevel) + self.window.setMovableByWindowBackground_(True) + self.window.setAcceptsMouseMovedEvents_(True) + + # Make window sticky across spaces (stays in fixed screen position) + self.window.setCollectionBehavior_( + NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorStationary + ) + + # Create custom view for handling mouse events + from AppKit import NSView + from objc import super as objc_super + + class DraggableImageView(NSView): + """Custom view that handles dragging and double-click""" + def initWithFrame_(self, frame): + self = objc_super(DraggableImageView, self).initWithFrame_(frame) + if self is None: + return None + self.image_view = NSImageView.alloc().initWithFrame_(self.bounds()) + self.image_view.setImageScaling_(1) # NSImageScaleProportionallyUpOrDown + self.addSubview_(self.image_view) + + # Create overlay view for toast (always on top) + # Make it non-opaque so it doesn't block the image + self.overlay_view = NSView.alloc().initWithFrame_(self.bounds()) + self.overlay_view.setWantsLayer_(True) + self.addSubview_(self.overlay_view) + + self.drag_start = None + return self + + def mouseDown_(self, event): + """Handle mouse down for dragging""" + if event.clickCount() == 2: + # Double-click to quit + from AppKit import NSApp + NSApp.terminate_(None) + else: + # Start dragging + self.drag_start = event.locationInWindow() + + def mouseDragged_(self, event): + """Handle mouse drag""" + if self.drag_start: + current_location = event.locationInWindow() + window_frame = self.window().frame() + + dx = current_location.x - self.drag_start.x + dy = current_location.y - self.drag_start.y + + new_origin = NSMakePoint( + window_frame.origin.x + dx, + window_frame.origin.y + dy + ) + + self.window().setFrameOrigin_(new_origin) + + def acceptsFirstMouse_(self, event): + """Accept first mouse click""" + return True + + # Create draggable view + self.content_view = DraggableImageView.alloc().initWithFrame_( + NSMakeRect(0, 0, self.display_width, self.display_height) + ) + self.image_view = self.content_view.image_view + self.overlay_view = self.content_view.overlay_view + self.window.setContentView_(self.content_view) + + # Animation state + self.current_state = 'idle' + self.frame_idx = 0 + + # Toast state + self.toast_label = None + self.toast_timer = None + self.toast_image = None + self.toast_window = None + + # Start animation timer + self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 1.0 / self.animations[self.current_state]['fps'], + self, + 'animate:', + None, + True + ) + + # Show window + self.window.makeKeyAndOrderFront_(None) + + # Start HTTP server + self._start_server() + + print(f"✓ macOS Pet started at ({x_pos}, {y_pos})") + print(f" Animations: {', '.join(self.animations.keys())}") + + def load_skin(self, skin_name=None): + """Load skin configuration and animations""" + available_skins = SkinLoader.list_skins() + if not available_skins: + raise FileNotFoundError(f"No skins found in {SKINS_DIR}") + + if skin_name is None or skin_name not in available_skins: + skin_name = available_skins[0] + + skin_path = os.path.join(SKINS_DIR, skin_name) + self.skin_config = SkinLoader.load_skin(skin_path) + + # Get display size + display_size = self.skin_config.get('size', {}) + self.display_width = display_size.get('width', 128) + self.display_height = display_size.get('height', 128) + + # Load animations + self.animations = {} + for anim_name, anim_config in self.skin_config['animations'].items(): + pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config) + + # Scale frames + scaled_frames = [] + for frame in pil_frames: + if frame.mode != 'RGBA': + frame = frame.convert('RGBA') + scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST) + scaled_frames.append(scaled) + + # Convert to NSImage with proper alpha handling + ns_images = [] + for pil_img in scaled_frames: + # Ensure RGBA mode for transparency + if pil_img.mode != 'RGBA': + pil_img = pil_img.convert('RGBA') + + # Convert PIL to PNG bytes (PNG preserves alpha channel) + png_buffer = io.BytesIO() + pil_img.save(png_buffer, format='PNG') + png_data = png_buffer.getvalue() + + # Create NSImage from PNG data + ns_data = NSData.dataWithBytes_length_(png_data, len(png_data)) + ns_image = NSImage.alloc().initWithData_(ns_data) + ns_images.append(ns_image) + + self.animations[anim_name] = { + 'frames': ns_images, + 'fps': anim_config.get('sprite', {}).get('fps', 6) + } + + def animate_(self, timer): + """Animation callback""" + anim = self.animations[self.current_state] + frames = anim['frames'] + + if frames: + self.image_view.setImage_(frames[self.frame_idx]) + self.frame_idx = (self.frame_idx + 1) % len(frames) + + def set_state(self, state): + """Change animation state (must be called on main thread)""" + if state in self.animations and state != self.current_state: + self.current_state = state + self.frame_idx = 0 + + # Update timer interval + self.timer.invalidate() + self.timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 1.0 / self.animations[self.current_state]['fps'], + self, + 'animate:', + None, + True + ) + print(f"→ State: {state}") + + def set_state_safe(self, state): + """Thread-safe wrapper for set_state""" + AppHelper.callAfter(lambda: self.set_state(state)) + + def show_toast(self, message): + """Show toast message above pet""" + from AppKit import NSImageView + + with open('/tmp/pet_toast_debug.log', 'a') as f: + f.write(f"[DEBUG] show_toast called with: {message}\n") + f.flush() + + if self.toast_window: + self.toast_window.orderOut_(None) + self.toast_window = None + self.toast_label = None + if self.toast_timer: + self.toast_timer.invalidate() + self.toast_timer = None + + bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2))) + bubble_pil = bubble_info['image'] + bubble_width, bubble_height = bubble_info['size'] + tail_x, tail_y = bubble_info['tail_tip'] + + png_buffer = io.BytesIO() + bubble_pil.save(png_buffer, format='PNG') + png_data = png_buffer.getvalue() + ns_data = NSData.dataWithBytes_length_(png_data, len(png_data)) + self.toast_image = NSImage.alloc().initWithData_(ns_data) + + pet_frame = self.window.frame() + anchor_x = pet_frame.origin.x + self.display_width * 0.75 + anchor_y = pet_frame.origin.y + self.display_height * 1.65 + toast_x = anchor_x - tail_x + toast_y = anchor_y - tail_y + + self.toast_window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( + NSMakeRect(toast_x, toast_y, bubble_width, bubble_height), + NSWindowStyleMaskBorderless, + NSBackingStoreBuffered, + False + ) + self.toast_window.setOpaque_(False) + self.toast_window.setBackgroundColor_(NSColor.clearColor()) + self.toast_window.setLevel_(NSFloatingWindowLevel) + self.toast_window.setIgnoresMouseEvents_(True) + self.toast_window.setHasShadow_(False) + + self.toast_label = NSImageView.alloc().initWithFrame_( + NSMakeRect(0, 0, bubble_width, bubble_height) + ) + self.toast_label.setImage_(self.toast_image) + self.toast_label.setImageScaling_(0) + self.toast_window.setContentView_(self.toast_label) + self.toast_window.orderFrontRegardless() + + with open('/tmp/pet_toast_debug.log', 'a') as f: + f.write(f"[DEBUG] Toast window shown at ({toast_x}, {toast_y}) size={bubble_info['size']} tail={bubble_info['tail_tip']}\n") + f.flush() + + self.toast_timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 3.0, + self, + 'hideToast:', + None, + False + ) + print(f"Toast: {message}") + + def show_toast_safe(self, message): + """Thread-safe wrapper for show_toast""" + from Foundation import NSRunLoop + from PyObjCTools import AppHelper + + # Write to log file for debugging + with open('/tmp/pet_toast_debug.log', 'a') as f: + f.write(f"[DEBUG] show_toast_safe called with: {message}\n") + f.flush() + + # Schedule on main thread + def show_on_main(): + with open('/tmp/pet_toast_debug.log', 'a') as f: + f.write(f"[DEBUG] show_on_main executing\n") + f.flush() + self.show_toast(message) + + AppHelper.callAfter(show_on_main) + + def hideToast_(self, timer): + """Hide toast message""" + if self.toast_window: + self.toast_window.orderOut_(None) + self.toast_window = None + self.toast_label = None + self.toast_image = None + self.toast_timer = None + + def _start_server(self): + """Start HTTP control server""" + pet = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + if 'state' in params: + state = params['state'][0] + pet.set_state_safe(state) + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + elif 'msg' in params: + msg = params['msg'][0] + pet.show_toast_safe(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'?state=idle/walk/run/sprint or ?msg=hello') + + def do_POST(self): + body = self.rfile.read(int(self.headers.get('Content-Length', 0))).decode() + if body: + pet.show_toast_safe(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 + + try: + HTTPServer.allow_reuse_address = True + srv = HTTPServer(('127.0.0.1', PORT), Handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk') + except OSError as e: + if e.errno == 48: + print(f'⚠ Port {PORT} already in use') + else: + raise + + def run(self): + """Run the application""" + AppHelper.runEventLoop() + +# ============================================================================ +# Windows Implementation - tkinter with transparentcolor +# ============================================================================ +else: + import tkinter as tk + from PIL import ImageTk + + class WinPet: + def __init__(self, skin_name=None): + self.root = tk.Tk() + self.root.wm_attributes('-topmost', True) + + # Load skin + self.load_skin(skin_name) + + # Setup window + screen_width = self.root.winfo_screenwidth() + screen_height = self.root.winfo_screenheight() + + x_pos = screen_width - 200 + y_pos = screen_height - 300 + + self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}') + self.root.overrideredirect(True) + self.root.wm_attributes('-topmost', True) + + # Transparent background + self.root.wm_attributes('-transparentcolor', '#F0F0F0') + self.root.config(bg='#F0F0F0') + + # Create label + self.label = tk.Label(self.root, bg='#F0F0F0', bd=0) + self.label.pack() + + # Bind events + self.label.bind('', lambda e: setattr(self, '_d', (e.x, e.y))) + self.label.bind('', self._drag) + self.label.bind('', lambda e: (self.root.destroy(), os._exit(0))) + + # Animation state + self.current_state = 'idle' + self.frame_idx = 0 + + # Toast state + self.toast_window = None + self.toast_photo = None + + # Start animation + self._animate() + self._start_server() + + print(f"✓ Windows Pet started at ({x_pos}, {y_pos})") + print(f" Animations: {', '.join(self.animations.keys())}") + + self.root.mainloop() + + def load_skin(self, skin_name=None): + """Load skin configuration and animations""" + available_skins = SkinLoader.list_skins() + if not available_skins: + raise FileNotFoundError(f"No skins found in {SKINS_DIR}") + + if skin_name is None or skin_name not in available_skins: + skin_name = available_skins[0] + + skin_path = os.path.join(SKINS_DIR, skin_name) + self.skin_config = SkinLoader.load_skin(skin_path) + + # Get display size + display_size = self.skin_config.get('size', {}) + self.display_width = display_size.get('width', 128) + self.display_height = display_size.get('height', 128) + + # Load animations + self.animations = {} + for anim_name, anim_config in self.skin_config['animations'].items(): + pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config) + + # Scale and convert frames + tk_frames = [] + for frame in pil_frames: + if frame.mode != 'RGBA': + frame = frame.convert('RGBA') + scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST) + tk_frames.append(ImageTk.PhotoImage(scaled)) + + self.animations[anim_name] = { + 'frames': tk_frames, + 'fps': anim_config.get('sprite', {}).get('fps', 6) + } + + def set_state(self, state): + """Change animation state""" + if state in self.animations and state != self.current_state: + self.current_state = state + self.frame_idx = 0 + print(f"→ State: {state}") + + def _drag(self, e): + x = self.root.winfo_x() + e.x - self._d[0] + y = self.root.winfo_y() + e.y - self._d[1] + self.root.geometry(f'+{x}+{y}') + + def _animate(self): + """Animate current state""" + if self.current_state not in self.animations: + self.root.after(100, self._animate) + return + + anim = self.animations[self.current_state] + frames = anim['frames'] + + if frames: + self.label.config(image=frames[self.frame_idx]) + self.frame_idx = (self.frame_idx + 1) % len(frames) + + delay = int(1000 / anim['fps']) + self.root.after(delay, self._animate) + + def show_toast(self, message): + """Show toast message above pet""" + if self.toast_window: + try: + self.toast_window.destroy() + except: + pass + self.toast_window = None + + bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2))) + bubble_pil = bubble_info['image'] + bubble_width, bubble_height = bubble_info['size'] + tail_x, tail_y = bubble_info['tail_tip'] + + self.toast_photo = ImageTk.PhotoImage(bubble_pil) + + self.toast_window = tk.Toplevel(self.root) + self.toast_window.overrideredirect(True) + self.toast_window.wm_attributes('-topmost', True) + self.toast_window.wm_attributes('-transparentcolor', '#00ff01') + self.toast_window.config(bg='#00ff01') + + toast_label = tk.Label( + self.toast_window, + image=self.toast_photo, + bg='#00ff01', + bd=0, + highlightthickness=0 + ) + toast_label.pack() + + pet_x = self.root.winfo_x() + pet_y = self.root.winfo_y() + anchor_x = pet_x + int(self.display_width * 0.75) + anchor_y = pet_y + toast_x = anchor_x - tail_x + toast_y = anchor_y - bubble_height + + self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}') + + self.root.after(3000, self._hide_toast) + print(f"Toast: {message}") + + def _hide_toast(self): + """Hide toast message""" + if self.toast_window: + try: + self.toast_window.destroy() + self.toast_window = None + except: + pass + + def _start_server(self): + """Start HTTP control server""" + pet = self + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + params = parse_qs(parsed.query) + + if 'state' in params: + state = params['state'][0] + pet.root.after(0, pet.set_state, state) + self.send_response(200) + self.end_headers() + self.wfile.write(b'ok') + elif 'msg' in params: + msg = params['msg'][0] + 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'?state=idle/walk/run/sprint or ?msg=hello') + + 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 + + try: + HTTPServer.allow_reuse_address = True + srv = HTTPServer(('127.0.0.1', PORT), Handler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + print(f'✓ Server: http://127.0.0.1:{PORT}/?state=walk') + except OSError as e: + if e.errno == 48: + print(f'⚠ Port {PORT} already in use') + else: + raise + + def run(self): + """Run the application (already in mainloop)""" + pass + +if __name__ == '__main__': + if sys.platform == 'darwin': + pet = MacPet() + pet.run() + else: + pet = WinPet() diff --git a/frontends/skins/boy/pet.png b/frontends/skins/boy/pet.png new file mode 100644 index 0000000..506622d Binary files /dev/null and b/frontends/skins/boy/pet.png differ diff --git a/frontends/skins/boy/skin.json b/frontends/skins/boy/skin.json new file mode 100644 index 0000000..498a182 --- /dev/null +++ b/frontends/skins/boy/skin.json @@ -0,0 +1,63 @@ +{ + "name": "Boy", + "version": "1.0.0", + "author": "pzuh", + "source": "https://pzuh.itch.io/temple-run-game-sprites", + "description": "Boy 角色皮肤", + "style": "pixel", + "format": "sprite", + "size": { + "width": 40, + "height": 61 + }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 3, + "startFrame": 20 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 10, + "startFrame": 20 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 64, + "frameHeight": 98, + "frameCount": 10, + "columns": 40, + "fps": 24, + "startFrame": 20 + } + } + } +} diff --git a/frontends/skins/boy/skin.png b/frontends/skins/boy/skin.png new file mode 100644 index 0000000..02413f3 Binary files /dev/null and b/frontends/skins/boy/skin.png differ diff --git a/frontends/skins/dinosaur/pet.png b/frontends/skins/dinosaur/pet.png new file mode 100644 index 0000000..ee2f5b0 Binary files /dev/null and b/frontends/skins/dinosaur/pet.png differ diff --git a/frontends/skins/dinosaur/skin.json b/frontends/skins/dinosaur/skin.json new file mode 100644 index 0000000..8d41f44 --- /dev/null +++ b/frontends/skins/dinosaur/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Dinosaur", + "version": "1.0.0", + "author": "voidcord54", + "source": "https://voidcord54.itch.io/", + "description": "像素风小恐龙 Dinosaur", + "style": "pixel", + "format": "sprite", + "size": { "width": 64, "height": 64 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 4, + "startFrame": 2 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 8, + "startFrame": 2 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 128, + "frameHeight": 128, + "frameCount": 2, + "columns": 5, + "fps": 16, + "startFrame": 2 + } + } + } +} diff --git a/frontends/skins/dinosaur/skin.png b/frontends/skins/dinosaur/skin.png new file mode 100644 index 0000000..60a596a Binary files /dev/null and b/frontends/skins/dinosaur/skin.png differ diff --git a/frontends/skins/doux/pet.png b/frontends/skins/doux/pet.png new file mode 100644 index 0000000..b4b17cc Binary files /dev/null and b/frontends/skins/doux/pet.png differ diff --git a/frontends/skins/doux/skin.json b/frontends/skins/doux/skin.json new file mode 100644 index 0000000..4b77cdd --- /dev/null +++ b/frontends/skins/doux/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Doux", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Doux", + "style": "pixel", + "format": "sprite", + "size": { "width": 48, "height": 48 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/doux/skin.png b/frontends/skins/doux/skin.png new file mode 100644 index 0000000..8fb8734 Binary files /dev/null and b/frontends/skins/doux/skin.png differ diff --git a/frontends/skins/glube/idle.png b/frontends/skins/glube/idle.png new file mode 100644 index 0000000..b2905cb Binary files /dev/null and b/frontends/skins/glube/idle.png differ diff --git a/frontends/skins/glube/pet.png b/frontends/skins/glube/pet.png new file mode 100644 index 0000000..28fe801 Binary files /dev/null and b/frontends/skins/glube/pet.png differ diff --git a/frontends/skins/glube/run.png b/frontends/skins/glube/run.png new file mode 100644 index 0000000..636ab8d Binary files /dev/null and b/frontends/skins/glube/run.png differ diff --git a/frontends/skins/glube/skin.json b/frontends/skins/glube/skin.json new file mode 100644 index 0000000..2815e26 --- /dev/null +++ b/frontends/skins/glube/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Glube", + "version": "1.0.0", + "author": "SketchesWithKevin", + "source": "https://sketcheswithkevin.itch.io/glube-platformer", + "description": "像素风小怪兽 Glube", + "style": "pixel", + "format": "sprite", + "size": { "width": 65, "height": 38 }, + "animations": { + "idle": { + "file": "idle.png", + "loop": true, + "sprite": { + "frameWidth": 44, + "frameHeight": 31, + "frameCount": 6, + "columns": 6, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "walk.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 6, + "startFrame": 0 + } + }, + "run": { + "file": "run.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 12, + "startFrame": 0 + } + }, + "sprint": { + "file": "run.png", + "loop": true, + "sprite": { + "frameWidth": 65, + "frameHeight": 32, + "frameCount": 8, + "columns": 8, + "fps": 24, + "startFrame": 0 + } + } + } +} diff --git a/frontends/skins/glube/walk.png b/frontends/skins/glube/walk.png new file mode 100644 index 0000000..636ab8d Binary files /dev/null and b/frontends/skins/glube/walk.png differ diff --git a/frontends/skins/line/License.txt b/frontends/skins/line/License.txt new file mode 100644 index 0000000..e0ec332 --- /dev/null +++ b/frontends/skins/line/License.txt @@ -0,0 +1,6 @@ +License is CC0 - https://creativecommons.org/public-domain/cc0/ + +YOU CAN: + +-> You can do whatever you want with this asset, including modifying it for commercial use. +-> Credit is not required, but is greatly appreciated! \ No newline at end of file diff --git a/frontends/skins/line/pet.png b/frontends/skins/line/pet.png new file mode 100644 index 0000000..4421167 Binary files /dev/null and b/frontends/skins/line/pet.png differ diff --git a/frontends/skins/line/skin.json b/frontends/skins/line/skin.json new file mode 100644 index 0000000..0b42e10 --- /dev/null +++ b/frontends/skins/line/skin.json @@ -0,0 +1,60 @@ +{ + "name": "Line", + "version": "1.0.0", + "author": "itch.io", + "source": "https://itch.io", + "description": "Line 角色皮肤", + "style": "pixel", + "format": "sprite", + "size": { "width": 39, "height": 46 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 4, + "columns": 28, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 6, + "startFrame": 4 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 10, + "startFrame": 12 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 156, + "frameHeight": 185, + "frameCount": 8, + "columns": 28, + "fps": 24, + "startFrame": 12 + } + } + } +} diff --git a/frontends/skins/line/skin.png b/frontends/skins/line/skin.png new file mode 100644 index 0000000..41b102e Binary files /dev/null and b/frontends/skins/line/skin.png differ diff --git a/frontends/skins/mort/pet.png b/frontends/skins/mort/pet.png new file mode 100644 index 0000000..1b4dcbe Binary files /dev/null and b/frontends/skins/mort/pet.png differ diff --git a/frontends/skins/mort/skin.json b/frontends/skins/mort/skin.json new file mode 100644 index 0000000..96483eb --- /dev/null +++ b/frontends/skins/mort/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Mort", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Mort", + "style": "pixel", + "format": "sprite", + "size": { "width": 48, "height": 48 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/mort/skin.png b/frontends/skins/mort/skin.png new file mode 100644 index 0000000..9089923 Binary files /dev/null and b/frontends/skins/mort/skin.png differ diff --git a/frontends/skins/tard/pet.png b/frontends/skins/tard/pet.png new file mode 100644 index 0000000..12c9aec Binary files /dev/null and b/frontends/skins/tard/pet.png differ diff --git a/frontends/skins/tard/skin.json b/frontends/skins/tard/skin.json new file mode 100644 index 0000000..3cb064a --- /dev/null +++ b/frontends/skins/tard/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Tard", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Tard", + "style": "pixel", + "format": "sprite", + "size": { "width": 48, "height": 48 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/tard/skin.png b/frontends/skins/tard/skin.png new file mode 100644 index 0000000..c99f59e Binary files /dev/null and b/frontends/skins/tard/skin.png differ diff --git a/frontends/skins/vita/pet.png b/frontends/skins/vita/pet.png new file mode 100644 index 0000000..005dff5 Binary files /dev/null and b/frontends/skins/vita/pet.png differ diff --git a/frontends/skins/vita/skin.json b/frontends/skins/vita/skin.json new file mode 100644 index 0000000..0978d20 --- /dev/null +++ b/frontends/skins/vita/skin.json @@ -0,0 +1,61 @@ +{ + "name": "Vita", + "version": "1.0.0", + "author": "arks", + "source": "https://arks.itch.io/dino-characters", + "license": "CC0", + "description": "像素风小恐龙 Vita", + "style": "pixel", + "format": "sprite", + "size": { "width": 128, "height": 128 }, + "animations": { + "idle": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 4, + "columns": 24, + "fps": 6, + "startFrame": 0 + } + }, + "walk": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 6, + "startFrame": 5 + } + }, + "run": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 8, + "columns": 24, + "fps": 16, + "startFrame": 6 + } + }, + "sprint": { + "file": "skin.png", + "loop": true, + "sprite": { + "frameWidth": 24, + "frameHeight": 24, + "frameCount": 6, + "columns": 24, + "fps": 16, + "startFrame": 17 + } + } + } +} diff --git a/frontends/skins/vita/skin.png b/frontends/skins/vita/skin.png new file mode 100644 index 0000000..1ee6744 Binary files /dev/null and b/frontends/skins/vita/skin.png differ diff --git a/frontends/stapp.py b/frontends/stapp.py index 0774849..8096a33 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -1,4 +1,6 @@ import os, sys, subprocess +from urllib.request import urlopen +from urllib.parse import quote 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') @@ -19,15 +21,13 @@ def init(): 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() + else: threading.Thread(target=agent.run, daemon=True).start() return agent agent = init() st.title("🖥️ Cowork") - if 'autonomous_enabled' not in st.session_state: st.session_state.autonomous_enabled = False @st.fragment @@ -38,50 +38,41 @@ def render_sidebar(): if last_reply_time > 0: st.caption(f"空闲时间:{int(time.time()) - last_reply_time}秒", help="当超过30分钟未收到回复时,系统会自动任务") if st.button("切换备用链路"): - agent.next_llm() - st.rerun(scope="fragment") + agent.next_llm(); st.rerun(scope="fragment") if st.button("强行停止任务"): - agent.abort() - st.toast("已发送停止信号") - st.rerun() + agent.abort(); st.toast("已发送停止信号"); st.rerun() if st.button("重新注入System Prompt"): - agent.llmclient.last_tools = '' - st.toast("下次将重新注入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) + pet_script = os.path.join(os.path.dirname(__file__), 'desktop_pet_v2.pyw') + if not os.path.exists(pet_script): pet_script = os.path.join(os.path.dirname(__file__), 'desktop_pet.pyw') + subprocess.Popen([sys.executable, pet_script], **kwargs) + def _pet_req(q): threading.Thread(target=lambda: urlopen(f'http://127.0.0.1:51983/?{q}', timeout=2), 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('✅ 任务已完成') - 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() + _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("桌面宠物已启动") 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() @@ -167,6 +158,7 @@ components.html(f'', height=0) if prompt := st.chat_input("请输入指令"): st.session_state.messages.append({"role": "user", "content": prompt}) + if hasattr(agent, '_pet_req') and not prompt.startswith('/'): agent._pet_req('state=walk') with st.chat_message("user"): st.markdown(prompt) with st.chat_message("assistant"):