🥰

Agent Skills対応デスクトップマスコットを作ったので紹介だけします。かわいいので

に公開
2026/04/12

かわいい

maid

これはなに

伺かとかDesktop Mate™みたいなやつです。

https://ssp.shillest.net/
https://www.infiniteloop.co.jp/desktopmate/

2026年はAI秘書元年だというのをどっかの国会議員[1]が言っていたので、じゃあデスクトップマスコットをAI秘書にしようということで作りました。

つくりかた

Pythonのパッケージマネージャーuvと、PythonについてくるGUIライブラリTkinter、Tkinterで画像を表示するための画像処理ライブラリPillow、 .envファイルから環境変数を読み込むライブラリpython-dotenv、そして自作AIエージェントライブラリMagicaを使います。

https://gitlab.com/tkithrta/magica
https://zenn.dev/tkithrta/articles/cd1d8a058014e1

まずStable DiffusionライブラリDiffusersのような画像生成AIでうちの子を生成して、Nano Banana Proでデスクトップマスコットっぽいポーズのグリーンバック画像に加工して、やる気を出します。

Gemini_Generated_Image_ckm8mhckm8mhckm8

このままだと使えないので、背景削除ライブラリRembgや、ググって出てきたPhotoroomの背景削除ツールを使ったりしてグリーンバックを透過PNGにしたり、Tkinterの-transparentcolorオプションでちゃんと透過できるように色を変えたり縁取りしたりします。

https://www.photoroom.com/tools/background-remover

ちなみに今回ここまでの作業が一番大変でした。

その後main.pyファイルを用意して、Magicaをインポートされたライブラリとして使用するためにインストールします。

uv run python -c "open('main.py','a').close()"
uv add --script=main.py git+https://gitlab.com/tkithrta/magica.git

ただ私のWindows環境にはGitがインストールされていないので、tar.gzファイルを展開してライブラリとしてインポートすることにします。

curl -fsSLO https://gitlab.com/tkithrta/magica/-/archive/main/magica-main.tar.gz
tar -xzf magica-main.tar.gz
uv run python -c "open('main.py','a').close()"
uv add --editable --script=main.py ./magica-main

あとはコーディングエージェントでバイブコーディングをするだけです。

main.py
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "magica",
#     "pillow==12.1.0",
#     "python-dotenv==1.2.1",
# ]
#
# [tool.uv.sources]
# magica = { path = "magica-main", editable = true }
# ///

import io
import os
import sys
import threading
import tkinter as tk
from contextlib import redirect_stderr, redirect_stdout
from tkinter import scrolledtext

from dotenv import load_dotenv
from magica import Agent
from PIL import Image, ImageTk

load_dotenv()


class MagicaMascot(tk.Tk):
    def __init__(self):
        super().__init__()
        skills_dir = os.path.expanduser(os.getenv("MAGICA_SKILLS_DIR", "~/.magica/skills"))
        self.agent = Agent(skills_dir)

        self.overrideredirect(True)
        if sys.platform == "win32":
            self.attributes("-topmost", True, "-transparentcolor", "#000000")
        else:
            self.attributes("-topmost", True)
        self.config(bg="#000000")

        image_path = os.path.join(os.path.dirname(__file__), "mascot.png")
        if os.path.exists(image_path):
            mascot_image = Image.open(image_path).convert("RGBA")
        else:
            mascot_image = Image.new("RGBA", (32, 32), (0, 0, 0, 255))
        self.photo = ImageTk.PhotoImage(mascot_image)
        label = tk.Label(self, image=self.photo, bg="#000000", bd=0)
        label.pack()
        label.bind("<Button-1>", self._click_start)
        label.bind("<B1-Motion>", self._drag_move)

        self.panel = tk.Toplevel(self)
        self.panel.overrideredirect(True)
        self.panel.attributes("-topmost", True)
        self.panel.config(bg="#333333", padx=10, pady=5)
        self.panel.protocol("WM_DELETE_WINDOW", self.destroy)

        self.header = tk.Frame(self.panel, bg="#333333")
        self.header.pack(fill=tk.X)
        self.header.bind("<Button-1>", self._click_start)
        self.header.bind("<B1-Motion>", self._drag_move)

        button_close = tk.Button(
            self.header,
            text="x",
            command=self.destroy,
            bg="#333333",
            fg="#CCCCCC",
            bd=0,
            font=("Arial", 10, "bold"),
            cursor="hand2",
        )
        button_close.pack(anchor="e")
        button_close.bind("<Enter>", lambda e: button_close.config(bg="#CC0000"))
        button_close.bind("<Leave>", lambda e: button_close.config(bg="#333333"))

        self.message_area = scrolledtext.ScrolledText(
            self.panel,
            width=40,
            height=12,
            bg="#000000",
            fg="#00FF00",
            bd=0,
            font=("Arial", 10, "bold"),
            wrap=tk.WORD,
        )
        self.message_area.pack(fill=tk.BOTH, expand=True)
        self.message_area.config(state=tk.DISABLED)

        self.entry = tk.Entry(
            self.panel,
            bg="#333333",
            fg="#FFFFFF",
            insertbackground="#FFFFFF",
            bd=0,
            font=("Arial", 12),
        )
        self.entry.pack(fill=tk.X, pady=10)
        self.entry.bind("<Return>", lambda e: self.process_task())

        self.geometry("+1200+300")
        self.panel.geometry("+1200+300")
        self.update_idletasks()
        self.panel.lift()
        self.entry.focus_set()

    def _click_start(self, e):
        self.offset_x = e.x_root - self.winfo_x()
        self.offset_y = e.y_root - self.winfo_y()
        self.panel.lift()
        self.entry.focus_set()

    def _drag_move(self, e):
        nx = e.x_root - self.offset_x
        ny = e.y_root - self.offset_y
        self.geometry(f"+{nx}+{ny}")
        self.panel.geometry(f"+{nx}+{ny}")

    def update_board(self, content):
        self.message_area.config(state=tk.NORMAL)
        self.message_area.delete(1.0, tk.END)
        self.message_area.insert(tk.END, content)
        self.message_area.see(tk.END)
        self.message_area.config(state=tk.DISABLED)

    def process_task(self):
        prompt = self.entry.get().strip()
        if not prompt or self.entry["state"] == tk.DISABLED:
            return
        self.entry.config(state=tk.DISABLED)
        self.update_board(f"> {prompt}\nThinking...")
        t = threading.Thread(target=self.execute_agent, args=(prompt,), daemon=True)
        t.start()

    def execute_agent(self, prompt):
        output_buffer = io.StringIO()
        try:
            with redirect_stdout(output_buffer), redirect_stderr(output_buffer):
                self.agent.run(prompt)
            output = output_buffer.getvalue() or "Done."
        except Exception as e:
            output = f"Error: {e}"
        self.after(0, self.finalize, f"> {prompt}\n{output}")

    def finalize(self, content):
        self.update_board(content)
        self.entry.config(state=tk.NORMAL)
        self.entry.delete(0, tk.END)
        self.entry.focus_set()


if __name__ == "__main__":
    app = MagicaMascot()
    app.mainloop()
uv addコマンドが不要になるGit用インラインスクリプトメタデータ
main.py
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
#     "magica",
#     "pillow==12.1.0",
#     "python-dotenv==1.2.1",
# ]
#
# [tool.uv.sources]
# magica = { git = "https://gitlab.com/tkithrta/magica.git" }
# ///

最後にmain.pyファイルがあるディレクトリにmascot.pngファイルと.envファイルを置いてMagicaの設定を行い、スキルを用意して起動します。

uv run main.py

uvwコマンドを使えばWindowsターミナルを閉じても動き続けます。

uvw run main.py

Tkinterの-transparentcolorオプションがWindows限定なのでいい感じに透過されませんが、一応Linuxでも動くみたいです(noVNCのFluxboxで確認)。

fluxbox

いいね

伺かは実験的にMCP対応しているみたいですが、Agent Skillsに対応したデスクトップマスコットを作ればMCPを使わずにスキルを作って簡単に機能を追加できるのでおすすめです。

https://forest.watch.impress.co.jp/docs/serial/yajiuma/2039405.html

脚注
  1. 安野貴博の自由研究 - 【2026年AI予想】来年のAI業界はどうなる?注目ポイントを大胆予想/AI秘書元年/企業採用が縮小?/AIドラマがヒット?/小さい言語モデルが実践投入?/科学技術論文が爆増? ↩︎

Discussion