Merge branch 'lsdefine:main' into main

This commit is contained in:
Shen Hao
2026-04-14 11:10:31 +08:00
committed by GitHub
30 changed files with 1503 additions and 25 deletions

View File

@@ -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

View File

@@ -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('<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)))
# 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()

BIN
frontends/skins/boy/pet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 B

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 567 B

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 B

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -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
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -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'<script>{_js_scroll_fix};{_js_ime_fix}</script>', 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"):