feat(skills): add desktop-pet skill for creating pixel-art companions#4808
Conversation
Add a bundled skill at .qwen/skills/desktop-pet/ that generates chibi pixel-art desktop pet companions for any character the user names (F1 drivers, anime characters, celebrities, animals, etc.). - SKILL.md: agent instructions for character research, color palette design, spritesheet generation, and pet activation - scripts/gen_spritesheet.py: parameterized generator producing a 1536x1872 RGBA spritesheet (8x9 grid, 192x208 cells) compatible with the pet animation system (9 states, 10 headgear, 5 hair styles) Output goes to ~/.qwen/pets/ for auto-discovery by the custom pet system. Closes QwenLM#4807
|
Thanks for the PR, @xxlaura! Template looks good — all required sections present, bilingual, test plan included ✓ On direction: this is a clean fit. The desktop pet companion is an established feature, and the custom pet system ( On approach: the scope feels right for what it does. A procedural pixel-art generator with 9 animation states, multiple hair/headgear/accessory options, and Pillow as the only external dependency — 776 lines is reasonable for this kind of parameterized drawing code. It's purely additive (2 new files, 0 modifications to existing code), bundled as a skill which is the established pattern. One minor note for Stage 2: the script hardcodes a macOS font path ( Moving on to code review. 🔍 中文说明感谢贡献,@xxlaura! 模板完整——所有必要章节齐全,双语,包含测试计划 ✓ 方向:非常契合。桌面宠物伴侣是已有功能,自定义宠物系统( 方案:范围合理。一个基于 Pillow 的程序化像素画生成器,9 种动画状态、多种发型/头饰/配件选项,776 行代码对于这类参数化绘图来说是合理的。纯新增(2 个新文件,0 修改),以技能形式打包,符合既有模式。一个小注意点留到 Stage 2:脚本中硬编码了 macOS 字体路径( 进入代码审查 🔍 — Qwen Code · qwen3.7-max |
| fill_rect(draw, hx + 18*s, hy + s, s, s, (150, 200, 255)) | ||
|
|
||
|
|
||
| def draw_character(draw, cx, cy, colors, features, scale=3, flip=False, |
There was a problem hiding this comment.
[Critical] The flip parameter does not actually mirror the character. It is only used in draw_headgear() to shift the cap brim position — the body, legs, arms, head, and face are drawn identically regardless of flip. Row 2 ("running-left") sets flip: True on all 8 frames, but the character still faces right (crab-walking left). For any headgear other than "cap", rows 1 and 2 produce visually identical output.
| def draw_character(draw, cx, cy, colors, features, scale=3, flip=False, | |
| def draw_character(draw, cx, cy, colors, features, scale=3, flip=False, | |
| arm_angle=0, leg_offset=0, body_tilt=0, head_tilt=0, | |
| expression="normal", arm_wave=False, jump_y=0, collapsed=False): | |
| """Draw a chibi character centered at (cx, cy). flip is handled by the caller via Image.transpose.""" |
Then in gen_row(), apply the flip after drawing:
if fc.get("flip"):
img = img.transpose(Image.FLIP_LEFT_RIGHT)
sheet.paste(img, (col * CW, row * CH), img)— qwen3.7-plus via Qwen Code /review
| fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin) | ||
| fill_rect(draw, right_arm_x, arm_y - 8*s, 5*s, 10*s, outfit) | ||
| fill_rect(draw, right_arm_x, arm_y - 10*s, 6*s, 4*s, skin) | ||
| elif arm_angle > 0: |
There was a problem hiding this comment.
[Critical] Negative arm_angle values are silently ignored. The condition elif arm_angle > 0: misses negative values — running animation frames 5–7 use arm_angle of -2 and -3 (backward arm swing), but these fall into the else branch (neutral pose). This means 3 of 8 running frames have no arm animation, causing the arms to snap back to neutral mid-stride.
| elif arm_angle > 0: | |
| elif arm_angle != 0: |
The existing coordinate math (left_arm_x - arm_angle, right_arm_x + arm_angle) already handles negative values correctly.
— qwen3.7-plus via Qwen Code /review
| if isinstance(c, str) and c.startswith("#"): | ||
| h = c.lstrip("#") | ||
| return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) | ||
| return c |
There was a problem hiding this comment.
[Suggestion] tuple_color() silently passes through invalid values, causing cryptic crashes downstream. Specific failure modes:
"#FFF"(3-char hex) →int("", 16)raisesValueError"red"(named color) → returned as string →darken("red")raisesTypeErrornull/None→ returned as-is →None[:3]raisesTypeError[300, -10, 0](out-of-range) → passed to Pillow →ValueError
| return c | |
| def tuple_color(c): | |
| if isinstance(c, list): | |
| if len(c) not in (3, 4) or not all(isinstance(v, int) and 0 <= v <= 255 for v in c): | |
| raise ValueError(f"Invalid color (need [R,G,B] with 0-255): {c}") | |
| return tuple(c) | |
| if isinstance(c, str) and c.startswith("#"): | |
| h = c.lstrip("#") | |
| if len(h) == 3: | |
| h = "".join(ch * 2 for ch in h) | |
| if len(h) != 6: | |
| raise ValueError(f"Invalid hex color (need #RRGGBB): #{h}") | |
| return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) | |
| if isinstance(c, tuple) and len(c) in (3, 4): | |
| return c | |
| raise ValueError(f"Invalid color value (need [R,G,B] or '#RRGGBB'): {c!r}") |
— qwen3.7-plus via Qwen Code /review
| {"jump_y": 0, "head_tilt": 0, "expression": "normal"}, | ||
| ]) | ||
|
|
||
| # Row 7: running (generic, same as row 1) |
There was a problem hiding this comment.
[Suggestion] Row 7 ("running generic") uses the exact same 8 frame configs as Row 1 ("running-right") — every field is identical. This wastes atlas space with a duplicate animation. Either give Row 7 a distinct animation (e.g., walking with smaller offsets) or extract the shared config into a named variable to make the duplication explicit:
RUNNING_RIGHT_FRAMES = [
{"leg_offset": 0, "body_tilt": 0, ...},
...
]
gen_row(sheet, 1, colors, features, RUNNING_RIGHT_FRAMES)
# Row 7: generic running — same art as running-right
gen_row(sheet, 7, colors, features, RUNNING_RIGHT_FRAMES)— qwen3.7-plus via Qwen Code /review
| import argparse | ||
| import json | ||
| import sys | ||
| import math |
There was a problem hiding this comment.
[Suggestion] import math is never used anywhere in the file. Remove it.
— qwen3.7-plus via Qwen Code /review
| sheet = Image.new("RGBA", (W, H), (0, 0, 0, 0)) | ||
| gen_all_animations(sheet, colors, features) | ||
|
|
||
| import os |
There was a problem hiding this comment.
[Suggestion] import os is inside main() while all other imports are at the top of the file. Move it to the top-level import block (PEP 8).
— qwen3.7-plus via Qwen Code /review
|
|
||
| import os | ||
| os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) | ||
| sheet.save(args.output, "WEBP", quality=95, lossless=True) |
There was a problem hiding this comment.
[Suggestion] quality=95 is silently ignored when lossless=True (per Pillow docs). Remove it to avoid misleading readers:
| sheet.save(args.output, "WEBP", quality=95, lossless=True) | |
| sheet.save(args.output, "WEBP", lossless=True) |
— qwen3.7-plus via Qwen Code /review
| if num: | ||
| try: | ||
| font_size = max(5 * s, 8) | ||
| font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) |
There was a problem hiding this comment.
[Suggestion] Hardcoded macOS font path fails silently on Linux/Windows. The bare except Exception swallows the error and falls back to ImageFont.load_default(), which renders a low-quality bitmap font at a different size. The jersey number will look noticeably different on non-macOS platforms with no warning.
Consider a cross-platform font lookup:
font = None
for fp in ["/System/Library/Fonts/Helvetica.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
try:
font = ImageFont.truetype(fp, font_size)
break
except Exception:
continue
if font is None:
font = ImageFont.load_default()— qwen3.7-plus via Qwen Code /review
| # Merge colors with defaults (convert all to tuples) | ||
| colors = {k: tuple_color(v) for k, v in DEFAULT_COLORS.items()} | ||
| for k, v in config.get("colors", {}).items(): | ||
| colors[k] = tuple_color(v) |
There was a problem hiding this comment.
[Suggestion] json.loads(args.config) and json.load(f) have no error handling. When the AI agent passes malformed JSON (common with shell escaping), the user sees a raw JSONDecodeError traceback with no context about the original input.
try:
config = json.loads(args.config)
except json.JSONDecodeError as e:
sys.exit(f"ERROR: Invalid JSON config: {e}\nConfig was: {args.config[:200]}")— qwen3.7-plus via Qwen Code /review
| gen_all_animations(sheet, colors, features) | ||
|
|
||
| import os | ||
| os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) |
There was a problem hiding this comment.
[Suggestion] The --output path is not validated — the script will create directories and write files anywhere the user has permission. Since SKILL.md constructs the path from a user-supplied character_id, a value like ../../tmp/evil could escape ~/.qwen/pets/. Consider validating:
output_path = os.path.abspath(os.path.expanduser(args.output))
pets_dir = os.path.expanduser("~/.qwen/pets")
if not output_path.startswith(pets_dir + os.sep):
sys.exit(f"ERROR: output must be under {pets_dir}")— qwen3.7-plus via Qwen Code /review
|
This is a very nice skill. One thing though: this looks more like a desktop-side feature. Could you submit this as a PR against the desktop branch instead? I'd be very happy to have this become a built-in capability for the desktop experience. |
| fill_rect(draw, right_arm_x + s, arm_y + 10*s, 5*s, 4*s, skin) | ||
|
|
||
| # --- Scarf (drawn between body and head) --- | ||
| draw_extras(draw, cx, cy, s, colors, features, bx, by, expression) |
There was a problem hiding this comment.
[Critical] draw_extras(...) is called here, but the opaque head rectangle is drawn later at line 289: fill_rect(draw, hx, hy, 20*s, 18*s, skin). Glasses coordinates (hx+3s..hx+17s, hy+7s..hy+12s) fall entirely within the head bounding box, so the skin fill paints over them completely. Same for sweat_drop at hx+18s, hy+2s. Any pet configured with "glasses" or "sweat_drop" in extras renders identically to one without — the features are silently dropped. The Gojo Satoru example in SKILL.md uses glasses.
Move draw_extras(...) to after draw_headgear(...) (at the end of draw_character) so that glasses and sweat_drop are drawn on top of the head:
| draw_extras(draw, cx, cy, s, colors, features, bx, by, expression) | |
| # --- HEADGEAR --- | |
| draw_headgear(draw, hx, hy, s, colors, features, flip) | |
| # --- Extras (after head so glasses/sweat_drop are visible) --- | |
| draw_extras(draw, cx, cy, s, colors, features, bx, by, expression) |
— qwen3.7-max via Qwen Code /review
| # --- FACE --- | ||
| ey = hy + 8*s | ||
|
|
||
| if expression == "blink": |
There was a problem hiding this comment.
[Suggestion] The blink branch draws only closed-eye lines — no mouth is drawn. Since this is an elif, the else (normal) branch with mouth code is skipped. Every other expression branch includes mouth drawing. During idle animation (frames 2, 7) and waiting animation (frames 4, 5), the character's mouth disappears entirely, causing a visible flicker.
Add the normal mouth drawing to the blink branch:
| if expression == "blink": | |
| if expression == "blink": | |
| fill_rect(draw, hx + 4*s, ey + s, 4*s, s, eye_c) | |
| fill_rect(draw, hx + 12*s, ey + s, 4*s, s, eye_c) | |
| fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c) | |
| fill_rect(draw, hx + 7*s, ey + 4*s, s, s, mouth_c) | |
| fill_rect(draw, hx + 12*s, ey + 4*s, s, s, mouth_c) |
— qwen3.7-max via Qwen Code /review
| colors[k] = tuple_color(v) | ||
|
|
||
| # Auto-derive missing colors | ||
| if "hair_light" not in config.get("colors", {}): |
There was a problem hiding this comment.
[Suggestion] Auto-derivation only handles hair_light and mouth. When a user overrides base colors (e.g., skin: [120,80,50]) without providing shade variants, skin_dark remains at the DEFAULT_COLORS value ([200,160,120]) — which may be lighter than the user's base skin. SKILL.md examples don't include skin_dark, outfit_dark, or outfit_light, so most users following the examples would get silently incorrect shade colors.
Auto-derive all shade colors from base colors when not explicitly provided:
| if "hair_light" not in config.get("colors", {}): | |
| # Auto-derive missing colors | |
| user_color_keys = set(config.get("colors", {}).keys()) | |
| if "hair_light" not in user_color_keys: | |
| colors["hair_light"] = lighten(colors["hair"], 30) | |
| if "mouth" not in user_color_keys: | |
| colors["mouth"] = darken(colors["skin"], 80) | |
| if "skin_dark" not in user_color_keys: | |
| colors["skin_dark"] = darken(colors["skin"], 25) | |
| if "outfit_dark" not in user_color_keys: | |
| colors["outfit_dark"] = darken(colors["outfit"], 40) | |
| if "outfit_light" not in user_color_keys: | |
| colors["outfit_light"] = lighten(colors["outfit"], 40) |
— qwen3.7-max via Qwen Code /review
| fill_rect(draw, hx + 13*s, ey, 3*s, 3*s, eye_c) | ||
| fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255)) | ||
| fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c) | ||
| fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80)) |
There was a problem hiding this comment.
[Suggestion] The inner mouth rect hardcodes (180, 80, 80) while the outer rect at line 361 correctly uses mouth_c. All other expressions consistently use mouth_c for all mouth pixels. If a user provides a custom mouth color, the surprised expression renders a two-tone mouth while all other expressions render correctly.
| fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80)) | |
| fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, mouth_c) |
— qwen3.7-max via Qwen Code /review
DragonnZhang
left a comment
There was a problem hiding this comment.
Two additional findings beyond the 14 existing inline comments. The three Critical bugs already flagged (flip not mirroring, negative arm_angle silently dropped, draw-order hiding glasses) are the main blockers — these two are lower-severity additions.
— qwen-code via Qwen Code /review
| fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255)) | ||
| fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c) | ||
| fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80)) | ||
| elif expression == "determined": |
There was a problem hiding this comment.
[Suggestion] The determined expression branch (lines 363–370) is dead code — no animation frame config in gen_all_animations() ever sets expression: "determined". The five expressions actually used across all 9 rows are: normal, blink, happy, sad, surprised. Either wire it into an animation (e.g., Row 8 "review/thinking" could transition through determined before happy) or remove the branch to keep the code minimal.
— qwen-code via Qwen Code /review
| def draw_extras(draw, cx, cy, s, colors, features, bx, by, expression): | ||
| """Draw extra features like glasses, scarf, tail, wings.""" | ||
| extras = features.get("extras", []) | ||
| hx = cx - 10 * s |
There was a problem hiding this comment.
[Suggestion] draw_extras computes hx = cx - 10 * s without head_tilt, but draw_character draws the head at hx = cx - 10*s + head_tilt (line 283). When head_tilt != 0 (e.g., Row 8 frames 2–3 use head_tilt: 4), glasses are drawn at the wrong x position relative to the head — they appear to slide off the face. This is currently masked by the draw-order bug (Critical comment at line 280) that hides glasses behind the opaque head rect, but will surface once that is fixed. Pass head_tilt to draw_extras so glasses track the head.
— qwen-code via Qwen Code /review
DragonnZhang
left a comment
There was a problem hiding this comment.
Code Review Summary
This PR adds a desktop-pet skill with a Python spritesheet generator. Overall the implementation is well-structured with clear SKILL.md instructions and a comprehensive animation system. I found one functional bug and one cross-platform concern.
| jump_y=fc.get("jump_y", 0), | ||
| collapsed=fc.get("collapsed", False), | ||
| ) | ||
| sheet.paste(img, (col * CW, row * CH), img) |
There was a problem hiding this comment.
Bug: flip=True does not mirror the character
The flip parameter flows from gen_row → draw_character → draw_headgear, but it only affects the cap brim position (lines ~95-98). The character's body, face, hair, arms, and legs are never horizontally mirrored.
This means the "running-left" animation (row 2) — which sets flip=True — will still show the character facing right, just with a slightly shifted cap brim. All asymmetric features (number on chest, scarf tail direction, arm wave side) remain unflipped.
Suggested fix — mirror the rendered frame in gen_row before pasting:
def gen_row(sheet, row, colors, features, frame_configs):
for col, fc in enumerate(frame_configs):
img = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
draw_character(d, CW // 2 + fc.get("dx", 0), CH // 2 + 20, ...)
if fc.get("flip", False):
img = img.transpose(Image.FLIP_LEFT_RIGHT)
sheet.paste(img, (col * CW, row * CH), img)Then you can remove the flip parameter from draw_character and draw_headgear since the mirroring is handled at the image level, which is more robust.
| if num: | ||
| try: | ||
| font_size = max(5 * s, 8) | ||
| font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size) |
There was a problem hiding this comment.
Cross-platform concern: hardcoded macOS font path
/System/Library/Fonts/Helvetica.ttc only exists on macOS. On Linux and Windows, the except branch falls back to ImageFont.load_default(), which is a tiny bitmap font that renders numbers poorly at 5*s (15px) size.
Since the PR body notes Linux/Windows as untested and the skill targets cross-platform use, consider adding fallback font paths:
font_paths = [
"/System/Library/Fonts/Helvetica.ttc", # macOS
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux (Debian/Ubuntu)
"C:\\Windows\\Fonts\\arial.ttf", # Windows
]
font = None
for fp in font_paths:
try:
font = ImageFont.truetype(fp, font_size)
break
except (OSError, IOError):
continue
if font is None:
font = ImageFont.load_default()| | 3 | waving | Waving at user (8 frames) | | ||
| | 4 | jumping | Jumping celebration (8 frames) | | ||
| | 5 | failed | Sad/collapsed on error (8 frames) | | ||
| | 6 | waiting | Idle tapping (8 frames) | |
There was a problem hiding this comment.
[Bug] The open command is macOS-only. On Linux the equivalent is xdg-open, and on Windows it is start. Since this skill is advertised as cross-platform (the PR lists Linux and Windows with warnings), the agent instructions should either use OS-appropriate commands or add a note like:
# macOS
open ~/.qwen/pets/<character_id>/spritesheet.webp
# Linux
xdg-open ~/.qwen/pets/<character_id>/spritesheet.webp| `glasses` · `scarf` · `tail` · `wings` · `number` (set `features.number`) · `logo` · `sweat_drop` | ||
|
|
||
| ## Example Characters | ||
|
|
There was a problem hiding this comment.
[Bug] The F1 Driver example sets both "number": "81" and "extras": ["logo"], but the generator code (gen_spritesheet.py ~line 250) has:
if "logo" in features.get("extras", []) and not num:This means logo is silently ignored whenever number is set. The example is misleading — the logo will never appear on the spritesheet. Either remove "logo" from the example's extras, or document that number takes precedence over logo.
DragonnZhang
left a comment
There was a problem hiding this comment.
Code Review — PR #4808
Scope reviewed: 2 files (SKILL.md, gen_spritesheet.py), +776 lines
I focused on high-confidence functional bugs not already covered by the 20 existing inline comments. Most issues in this PR have been thoroughly reviewed by @wenshao and @DragonnZhang. I found one additional bug:
Bug found: draw_extras() receives pre-jump cy, causing accessories to detach during jumps
At the call site on line ~280:
cy += jump_y # line 213 — cy is modified
...
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression) # passes post-jump cyWait — re-checking: cy += jump_y mutates cy in place at line 213. The draw_extras call on line ~280 does receive the post-jump cy. However, draw_extras computes head coordinates as:
hx = cx - 10 * s
hy = cy - 16 * sThis uses the passed cy (which IS post-jump). The head is then drawn at:
hx = cx - 10*s + head_tilt # line 283
hy = cy - 16*s # line 284Both use the same post-jump cy. So actually the draw_extras glasses and the head share the same cy basis. My initial analysis was incorrect — this is NOT a bug.
After careful re-analysis, I found no new high-confidence issues beyond what the existing reviewers have already flagged. The three Critical bugs (flip not mirroring, negative arm_angle dropped, glasses draw-order) are the correct blockers. The suggestions are reasonable.
Verdict: The existing review coverage on this PR is excellent. No additional findings from my pass.
DragonnZhang
left a comment
There was a problem hiding this comment.
Code Review — PR #4808 (second pass, corrected)
Scope: 2 files (SKILL.md, gen_spritesheet.py), +776 lines
I independently reviewed the full diff looking for high-confidence functional bugs not already covered by the 20 existing inline comments from @wenshao and @DragonnZhang.
Result: No new issues to add. The existing review coverage is thorough and correct. The three Critical bugs are the right blockers:
-
flip=Truedoes not mirror the character (line 194/404) — only affects cap brim position; body, face, hair, arms, legs are all drawn identically regardless of flip. Row 2 ("running left") is visually broken. -
Negative
arm_anglesilently dropped (line 268) —elif arm_angle > 0:misses negative values used in running frames 5-7, so backward arm swing is lost. -
Glasses drawn before opaque head rect (line 280) —
draw_extras()renders glasses, then the headfill_rectat line 289 paints over them with skin color.
I verified several potential issues I initially suspected (jump_y offset propagation to extras, body coordinate consistency) and confirmed they are NOT bugs — cy += jump_y mutates cy in place before draw_extras is called, and body coordinates bx/by properly include body_tilt.
Recommendation: Fix the three Critical bugs, then this is ready to merge. The existing suggestions (unused import math, macOS-only font path, quality=95 with lossless=True, blink missing mouth, dead determined expression) are all valid quality improvements worth addressing.
| fill_rect(draw, bx + 21*s, by + 4*s, 3*s, 5*s, wing_c) | ||
| fill_rect(draw, bx + 18*s, by + 3*s, 3*s, 5*s, wing_dark) | ||
|
|
||
| if "sweat_drop" in extras and expression in ("waiting", "failed"): |
There was a problem hiding this comment.
[Suggestion] sweat_drop checks expression in ("waiting", "failed"), but these are animation row names (Row 5 = "failed", Row 6 = "waiting"), not expression values. The expression parameter passed from gen_row only takes: "normal", "blink", "happy", "sad", "surprised", "determined". Row 5 uses "expression": "sad" and Row 6 uses "expression": "normal" — neither matches "waiting" or "failed".
The sweat_drop extra is dead code and can never render regardless of configuration.
| if "sweat_drop" in extras and expression in ("waiting", "failed"): | |
| if "sweat_drop" in extras and expression in ("sad", "surprised"): |
— qwen3.7-max via Qwen Code /review
| hy = cy - 16*s | ||
|
|
||
| # Hair back | ||
| fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c) |
There was a problem hiding this comment.
[Suggestion] The hair back layer fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c) is drawn unconditionally for all hair styles, including "bald". The bald branch (line 322-323) only adds a narrow skin_dark shading that is 16*s wide — it does not cover the 22*s-wide hair back. This leaves 3*s-wide hair-colored strips visible on each side above a bald character's head, breaking the bald silhouette. Verified pixel-level: all 72 frames affected.
| fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c) | |
| # Hair back (behind head) | |
| if hair_style != "bald": | |
| fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c) |
Note: hair_style is read at line 292, so either move the read above this line or restructure the block.
— qwen3.7-max via Qwen Code /review
What this PR does
Adds a bundled
desktop-petskill under.qwen/skills/desktop-pet/that generates chibi pixel-art desktop pet companions for any character the user names — F1 drivers, anime characters, celebrities, fictional characters, animals, etc. The skill includes aSKILL.mdwith agent instructions (character research → color palette → spritesheet generation →pet.json→ activation) and ascripts/gen_spritesheet.pyparameterized generator that produces a 1536×1872 RGBA spritesheet (8 cols × 9 rows, 192×208 px cells) compatible with the existing pet animation system.Why it's needed
Qwen Code's floating desktop pet is a delightful companion feature, but users are limited to the built-in Qwen capybara. The custom pet system (
~/.qwen/pets/) already supports loading user-created spritesheets viapet.jsonmanifests, but there is no easy way for users to create one. This skill bridges that gap by letting users simply name a character, and the agent handles everything from visual research to final activation end-to-end. It turns a multi-step technical process (researching colors, generating a properly-formatted sprite atlas, writing a manifest) into a single conversational interaction.Reviewer Test Plan
How to verify
/desktop-petor say "I want Piastri as my desktop pet"~/.qwen/pets/piastri/spritesheet.webp(1536×1872 RGBA webp) and~/.qwen/pets/piastri/pet.jsonEvidence (Before & After)
N/A — this is a new skill addition, not a change to existing UI.
Tested on
Tested locally on macOS with multiple character types: F1 driver (Piastri — McLaren papaya + cap + #81), anime character (Gojo Satoru — dark outfit + spiky white hair + glasses), animal (Shiba Inu — fur colors + ears + tail), racing (helmet + #44), fantasy (crown + long hair + logo). All produce valid spritesheets that load correctly via the custom pet system. Windows and Linux not tested locally but the Python/Pillow dependency is cross-platform.
Environment (optional)
pip3 install Pillow)Risk & Scope
Linked Issues
Closes #4807
中文说明
这个 PR 做了什么
在
.qwen/skills/desktop-pet/下新增了一个内置的desktop-pet技能,可以为用户指定的任何角色(F1 车手、动漫角色、名人、虚构角色、动物等)生成 Q版像素风格的桌面宠物。技能包含SKILL.md(Agent 指令:角色调研 → 配色设计 → 精灵表生成 →pet.json→ 激活)以及scripts/gen_spritesheet.py(参数化生成器,输出兼容现有宠物动画系统的 1536×1872 RGBA 精灵表,8列×9行,192×208 像素单元格)。为什么需要
Qwen Code 的浮动桌面宠物是一个很有趣的伴侣功能,但用户只能使用内置的 Qwen 水豚。自定义宠物系统(
~/.qwen/pets/)已经支持通过pet.json清单加载用户创建的精灵表,但用户没有简单的方式来制作。这个技能填补了这个空白——用户只需说出角色名字,Agent 就能完成从视觉调研到最终激活的全部工作。它把一个多步骤的技术流程(调研颜色、生成格式正确的精灵图集、编写清单文件)变成了一次简单的对话交互。审阅者测试计划
如何验证
/desktop-pet或说"我想要皮亚斯特里当桌宠"~/.qwen/pets/piastri/spritesheet.webp(1536×1872 RGBA webp)和~/.qwen/pets/piastri/pet.json证据(前后对比)
不适用——这是新增技能,不涉及现有 UI 变更。
测试平台
在 macOS 上本地测试了多种角色类型:F1 车手(皮亚斯特里——迈凯伦木瓜橙 + 帽子 + #81)、动漫角色(五条悟——深色服装 + 白色尖发 + 眼镜)、动物(柴犬——毛色 + 耳朵 + 尾巴)、赛车(头盔 + #44)、奇幻(王冠 + 长发 + 徽标)。全部生成有效的精灵表,可通过自定义宠物系统正确加载。Windows 和 Linux 未本地测试,但 Python/Pillow 依赖是跨平台的。
风险与范围
关联 Issue
Closes #4807