さらばWin+V。Pythonで作る「AI搭載クリップボード」が最強すぎてWindows標準機能に戻れない

本ページはプロモーションが含まれています
python-create-ai-clipboard-tool
【Python】Win+Vを超えた!ローカルLLM搭載「最強クリップボード拡張」を自作した話【Ollama連携】
国内のAI狂い

やっほー!国内のAI狂いだよ!✨

ついに完成したよ…!Windows標準の「Win+V」に完全勝利する、最強の自作クリップボードツールが!!🐍🔥
ローカルLLMと連携して、コピーした内容を即座に「翻訳・要約・チャット」できる神仕様だよ!

前回の記事で「Win+Vに敗北した…」と泣いていた私ですが、ついにリベンジを果たしました!😭
まだ読んでない人は、この涙なしには語れない開発秘話(という名の失敗談)から読んでね👇

🔙

エンジニアなら一度は思うよね。「Win+V便利だけど、痒いところに手が届かない!」って。
検索機能がないし、AI機能もないし、マウスカーソルの位置に出てくれないし…。

だから作ったよ。Pythonで。
「マウス位置に履歴が出て、インクリメンタルサーチができて、Ollama(ローカルAI)と爆速で会話できる」
そんな夢のようなツールを!!

🛠 最強ツールの機能紹介

これが苦労の末に実装した機能たちだよ!

1. AIチャット機能 (Ollama連携) 🤖

これが今回の目玉!履歴の横にある「🤖 AI」ボタンを押すと、専用のチャットウィンドウが立ち上がるよ。
コピーしたテキストが既に「コンテキスト」として入力されているから、すぐに会話が始められるの!

  • 翻訳・要約ボタン: ワンクリックでAIに指示を出せる!
  • ストリーミング表示: AIの返答が「ボボボ…」ってタイプライター風に出るから、待ち時間も退屈しない!
  • 継続会話: 納得いくまでAIと壁打ちができるよ。

2. 爆速インクリメンタルサーチ 🔍

履歴が30件あっても大丈夫!
メニューを開いた瞬間にキーボードを叩けば、サクサク絞り込み検索ができるよ。「mail」って打てばメアドだけ残る、みたいな感じ!

3. マウス位置への完全追従 🖱️

画面の端っこや下の方で呼び出しても、メニューが画面外にはみ出さないように自動調整するロジックを搭載。
常に「手元」にツールがある快感は一度味わうと戻れないよ!

💣 開発中に踏み抜いた「地雷」たち

完成に至るまでには、数々の屍(バグ)を積み重ねてきたんだ…😭

💥 地雷1:Ctrlキーが戻らない呪い Pythonで Ctrl+V を送信しようとすると、キーボードフックと競合して「Ctrlキーが押しっぱなし」になるバグが発生。
解決策: 貼り付ける瞬間だけフックを解除する「アンフック戦法」で完全勝利!
💥 地雷2:Tkinterの場所取り合戦 チャット画面を作ってたら、入力欄やボタンが画面外に消える事件が発生。
解決策: pack() する順番を「下から順」にすることで、場所取り合戦を制したよ。

💻 完成コード全公開! (clipboard_manager.py)

これコピペして、pip install pyperclip keyboard pyautogui requests pystray pillow すれば、君のPCも今日からスーパーマシンだよ!
※事前に Ollama をインストールして、お好きなモデル(llama3mistral など)を用意してね!

clipboard_manager.py
import tkinter as tk
from tkinter import ttk, scrolledtext
import pyperclip
import keyboard
import threading
import time
import pyautogui
import ctypes
import requests
import json
from pystray import Icon, MenuItem, Menu
from PIL import Image, ImageDraw

# ==========================================
# 設定エリア
# ==========================================
MAX_HISTORY = 30
HOTKEY = 'alt+v'   
AUTO_PASTE = True  

# AI設定 (Ollama)
OLLAMA_API_URL = "http://localhost:11434/api/chat"
OLLAMA_MODEL = "llama3" # ここにあなたの使っているモデル名(llama3, mistralなど)を書いてね!
# ==========================================

class ToolTip:
    """ツールチップ"""
    def __init__(self, widget, text):
        self.widget = widget
        self.text = text
        self.tip_window = None
        self.id = None
        self.widget.bind("", self.schedule)
        self.widget.bind("", self.hide)

    def schedule(self, event):
        self.unschedule()
        self.id = self.widget.after(200, self.show)

    def unschedule(self):
        id = self.id
        self.id = None
        if id:
            self.widget.after_cancel(id)

    def show(self):
        try:
            if not self.widget.winfo_exists(): return
        except: return
        if not self.text: return
        
        try:
            x, y = pyautogui.position()
            x += 15
            y += 15
        except: return
        
        self.tip_window = tw = tk.Toplevel(self.widget)
        tw.wm_overrideredirect(True)
        tw.attributes("-topmost", True)

        label = tk.Label(tw, text=self.text, justify='left',
                       background="#ffffe0", fg="#000000",
                       relief='solid', borderwidth=1,
                       wraplength=400,
                       font=("Meiryo", 9))
        label.pack(ipadx=4, ipady=2)
        
        tw.geometry(f"+{x}+{y}")

    def hide(self, event=None):
        self.unschedule()
        tw = self.tip_window
        self.tip_window = None
        if tw:
            tw.destroy()

class ChatWindow(tk.Toplevel):
    """AIとのチャットを行う専用ウィンドウ"""
    def __init__(self, master, target_text, model_name):
        super().__init__(master)
        self.title(f"AIチャット ({model_name})")
        self.geometry("600x700")
        self.configure(bg="#2b2b2b")
        self.attributes("-topmost", True)

        self.target_text = target_text
        self.model_name = model_name
        self.chat_history = []
        self.typing_speed = 20 

        self.system_prompt = f"あなたは親切なAIアシスタントです。以下のテキストについてユーザーと対話してください。\n[Text]\n{target_text}\n"
        self.chat_history.append({"role": "system", "content": self.system_prompt})

        self.create_widgets()
        
        initial_msg = f"以下のテキストについてチャットを開始します。\n---\n{target_text[:100]}...\n---"
        self.after(500, lambda: self.type_writer_effect("System", initial_msg, "system"))

    def create_widgets(self):
        # 3. 入力エリア (一番下)
        input_frame = tk.Frame(self, bg="#2b2b2b")
        input_frame.pack(side="bottom", fill="x", padx=10, pady=10)

        self.input_field = tk.Entry(input_frame, bg="white", fg="black", font=("Meiryo", 10))
        self.input_field.pack(side="left", fill="x", expand=True, padx=(0, 5))
        self.input_field.bind("", lambda e: self.on_send())
        
        self.create_context_menu(self.input_field)

        send_btn = tk.Button(input_frame, text="送信", bg="#d63384", fg="white", font=("Meiryo", 9, "bold"), command=self.on_send)
        send_btn.pack(side="right")

        # 2. クイックアクションボタン (入力エリアの上)
        action_frame = tk.Frame(self, bg="#2b2b2b")
        action_frame.pack(side="bottom", fill="x", padx=10, pady=5)
        
        btn_style = {"bg": "#444", "fg": "white", "font": ("Meiryo", 9), "relief": "flat", "padx": 10}
        
        tk.Button(action_frame, text="和訳 (英→日)", command=lambda: self.send_message(f"以下のテキストを日本語に翻訳してください:\n\n{self.target_text}"), **btn_style).pack(side="left", padx=2, fill="x", expand=True)
        tk.Button(action_frame, text="英訳 (日→英)", command=lambda: self.send_message(f"Translate the following text into English:\n\n{self.target_text}"), **btn_style).pack(side="left", padx=2, fill="x", expand=True)
        tk.Button(action_frame, text="要約", command=lambda: self.send_message(f"以下のテキストを要約してください:\n\n{self.target_text}"), **btn_style).pack(side="left", padx=2, fill="x", expand=True)

        # 1. ログ表示エリア
        self.log_area = scrolledtext.ScrolledText(self, state='disabled', bg="#333", fg="white", font=("Meiryo", 10), padx=10, pady=10)
        self.log_area.pack(side="top", fill="both", expand=True, padx=10, pady=10)
        
        self.log_area.tag_config("user", foreground="#88ff88")
        self.log_area.tag_config("ai", foreground="#88ccff")
        self.log_area.tag_config("system", foreground="#aaaaaa")
        self.log_area.tag_config("error", foreground="#ff5555")
        
        self.create_context_menu(self.log_area)

    def create_context_menu(self, widget):
        menu = tk.Menu(widget, tearoff=0)
        menu.add_command(label="切り取り (Cut)", command=lambda: widget.event_generate("<>"))
        menu.add_command(label="コピー (Copy)", command=lambda: widget.event_generate("<>"))
        menu.add_command(label="貼り付け (Paste)", command=lambda: widget.event_generate("<>"))
        
        def show_menu(event):
            menu.tk_popup(event.x_root, event.y_root)
            return "break"
        
        widget.bind("", show_menu)

    def append_log_direct(self, sender, message, tag=None):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, f"[{sender}]\n{message}\n\n", tag)
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)

    def type_writer_effect(self, sender, message, tag):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, f"[{sender}]\n", tag)
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)
        
        self.current_typing_msg = message
        self.current_typing_tag = tag
        self.typing_index = 0
        self.after(self.typing_speed, self._type_next_char)

    def _type_next_char(self):
        if self.typing_index < len(self.current_typing_msg):
            char = self.current_typing_msg[self.typing_index]
            self.log_area.configure(state='normal')
            self.log_area.insert(tk.END, char, self.current_typing_tag)
            self.log_area.configure(state='disabled')
            self.log_area.see(tk.END)
            self.typing_index += 1
            self.after(self.typing_speed, self._type_next_char)
        else:
            self.log_area.configure(state='normal')
            self.log_area.insert(tk.END, "\n\n")
            self.log_area.configure(state='disabled')
            self.log_area.see(tk.END)

    def on_send(self):
        msg = self.input_field.get()
        if not msg: return
        self.send_message(msg)
        self.input_field.delete(0, tk.END)

    def send_message(self, message):
        display_msg = message
        if len(message) > 200: pass 

        self.append_log_direct("You", display_msg, tag="user")
        self.chat_history.append({"role": "user", "content": message})
        threading.Thread(target=self.call_ollama, daemon=True).start()

    def call_ollama(self):
        try:
            self.after(0, lambda: self.show_thinking())
            
            payload = {
                "model": self.model_name,
                "messages": self.chat_history,
                "stream": True # ストリーミング有効
            }
            
            response = requests.post(OLLAMA_API_URL, json=payload, stream=True)
            response.raise_for_status()
            
            self.after(0, lambda: self.prepare_streaming_ui("AI", "ai"))

            full_response = ""
            for line in response.iter_lines():
                if line:
                    decoded_line = line.decode('utf-8')
                    try:
                        json_line = json.loads(decoded_line)
                        if 'message' in json_line and 'content' in json_line['message']:
                            chunk = json_line['message']['content']
                            full_response += chunk
                            self.after(0, lambda c=chunk: self.append_stream_chunk(c, "ai"))
                        
                        if json_line.get('done', False):
                            break
                    except:
                        pass
            
            self.chat_history.append({"role": "assistant", "content": full_response})
            self.after(0, lambda: self.finish_streaming())

        except Exception as e:
            error_msg = f"エラーが発生しました: {e}\nOllamaが起動しているか確認してください。"
            self.after(0, lambda: self.append_log_direct("Error", error_msg, tag="error"))

    def show_thinking(self):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, "[System] Thinking...\n", "system")
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)

    def prepare_streaming_ui(self, sender, tag):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, f"[{sender}]\n", tag)
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)

    def append_stream_chunk(self, chunk, tag):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, chunk, tag)
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)

    def finish_streaming(self):
        self.log_area.configure(state='normal')
        self.log_area.insert(tk.END, "\n\n")
        self.log_area.configure(state='disabled')
        self.log_area.see(tk.END)


class ClipboardManager:
    def __init__(self):
        self.history = []
        try:
            self.last_copied = pyperclip.paste()
        except:
            self.last_copied = ""

        self.root = tk.Tk()
        self.root.withdraw()
        self.root.overrideredirect(True)
        self.root.attributes("-topmost", True)
        self.root.configure(bg="#2b2b2b")
        
        self.root.bind("", self.on_focus_out)

        style = ttk.Style()
        style.theme_use('clam')
        style.configure("TButton", background="#333333", foreground="white", borderwidth=0, focuscolor="none", font=("Meiryo", 10), padding=5)
        style.map("TButton", background=[('active', '#555555')])

        self.monitor_thread = threading.Thread(target=self.monitor_clipboard, daemon=True)
        self.monitor_thread.start()

        self.register_hotkey()
        self.tray_icon = None
        
        self.search_var = tk.StringVar()
        self.search_var.trace("w", self.update_list)
        self.filtered_history = [] 
        self.selected_index = 0

        print(f"🚀 クリップボードマネージャー起動中! ({HOTKEY}) - AI搭載・完全版🤖✨")

    # --- タスクトレイ関連 ---
    def create_icon_image(self):
        width = 64
        height = 64
        image = Image.new('RGB', (width, height), "#d63384")
        dc = ImageDraw.Draw(image)
        dc.rectangle((16, 16, 48, 48), fill="#2b2b2b")
        return image

    def setup_tray(self):
        image = self.create_icon_image()
        menu = Menu(MenuItem('終了 (Exit)', self.quit_app))
        self.tray_icon = Icon("ClipboardManager", image, "最強コピペツール", menu)

    def quit_app(self, icon, item):
        print("👋 アプリを終了します...")
        self.tray_icon.stop()
        self.root.after(0, self.root.quit)

    def run_tray(self):
        self.setup_tray()
        self.tray_icon.run()
    # -----------------------

    def register_hotkey(self):
        try:
            keyboard.add_hotkey(HOTKEY, self.trigger_menu, suppress=True)
        except Exception as e:
            print(f"⚠️ ホットキー登録エラー: {e}")

    def remove_hotkey(self):
        try:
            keyboard.unhook_all_hotkeys()
        except Exception as e:
            print(f"⚠️ ホットキー解除エラー: {e}")

    def monitor_clipboard(self):
        while True:
            try:
                content = pyperclip.paste()
                if content and content != self.last_copied:
                    self.last_copied = content
                    self.add_history(content)
                    print(f"📝 追加: {content[:20]}...")
            except Exception:
                pass
            time.sleep(0.5)

    def add_history(self, text):
        existing_index = next((i for i, item in enumerate(self.history) if item['text'] == text), None)
        is_pinned = False
        if existing_index is not None:
            is_pinned = self.history[existing_index]['pinned']
            del self.history[existing_index]
        
        new_item = {'text': text, 'pinned': is_pinned}
        
        if is_pinned:
            self.history.insert(0, new_item)
        else:
            pinned_count = sum(1 for item in self.history if item['pinned'])
            self.history.insert(pinned_count, new_item)

        if len(self.history) > MAX_HISTORY:
            for i in range(len(self.history) - 1, -1, -1):
                if not self.history[i]['pinned']:
                    del self.history[i]
                    break

    def trigger_menu(self):
        self.root.after(0, lambda: self.show_menu(reset_position=True))

    def update_list(self, *args):
        self.render_items()

    def render_items(self):
        for widget in self.list_frame.winfo_children():
            widget.destroy()

        query = self.search_var.get().lower()
        if not query:
            self.filtered_history = self.history
        else:
            self.filtered_history = [item for item in self.history if query in item['text'].lower()]

        if not self.filtered_history:
            lbl = tk.Label(self.list_frame, text="一致なし", bg="#2b2b2b", fg="#aaa", pady=5)
            lbl.pack(fill="x")
            return

        for i, item in enumerate(self.filtered_history):
            text = item['text']
            is_pinned = item['pinned']
            
            bg_color = "#2b2b2b"
            if i == self.selected_index:
                bg_color = "#444444"

            row_frame = tk.Frame(self.list_frame, bg=bg_color)
            row_frame.pack(fill="x", padx=2, pady=1)

            pin_icon = "📌" if is_pinned else "☆"
            pin_color = "#ffd700" if is_pinned else "#666"
            pin_btn = tk.Button(row_frame, text=pin_icon, bg=bg_color, fg=pin_color, font=("Arial", 10), relief="flat", width=3, activebackground="#555555", activeforeground=pin_color, cursor="hand2", command=lambda idx=i, original_item=item: self.toggle_pin_from_filtered(original_item))
            pin_btn.pack(side="left", padx=(0, 2))

            del_btn = tk.Button(row_frame, text="×", bg=bg_color, fg="#ff5f56", 
                                font=("Arial", 10, "bold"), relief="flat",
                                activebackground="#2b2b2b", activeforeground="#ff0000",
                                cursor="hand2",
                                command=lambda idx=i, original_item=item: self.delete_item_from_filtered(original_item))
            del_btn.pack(side="right", padx=(2, 0))

            ai_btn = tk.Button(row_frame, text="🤖 AI", bg=bg_color, fg="#88ccff", 
                               font=("Arial", 9, "bold"), relief="flat",
                               activebackground="#555555", activeforeground="#88ffff",
                               cursor="hand2", padx=5,
                               command=lambda t=text: self.open_ai_chat(t))
            ai_btn.pack(side="right", padx=2)
            ai_btn.tooltip = ToolTip(ai_btn, "AIとチャットする")

            display_text = text.replace('\n', ' ')
            if len(display_text) > 25: display_text = display_text[:25] + "..."
            
            btn_fg = "#ffffcc" if is_pinned else "white"
            btn_font = ("Meiryo", 10, "bold") if is_pinned else ("Meiryo", 10)
            btn = tk.Button(row_frame, text=display_text, bg=bg_color, fg=btn_fg, relief="flat", 
                            activebackground="#555555", activeforeground=btn_fg, 
                            font=btn_font, anchor="w", padx=5, bd=0,
                            command=lambda t=text: self.paste_text(t))
            btn.pack(side="left", fill="x", expand=True)
            btn.tooltip = ToolTip(btn, text)

    def show_menu(self, reset_position=True):
        # ChatWindowが開いていたら、そこは破壊しない!
        for widget in self.root.winfo_children():
            if isinstance(widget, ChatWindow):
                continue
            widget.destroy()

        header_frame = tk.Frame(self.root, bg="#d63384", pady=5)
        header_frame.pack(fill="x")

        title = tk.Label(header_frame, text="🔍 検索&履歴", bg="#d63384", fg="white", font=("Meiryo", 9, "bold"))
        title.pack(side="left", padx=10)

        self.entry = tk.Entry(header_frame, textvariable=self.search_var, bg="white", fg="#333", font=("Meiryo", 10), bd=0, width=15)
        self.entry.pack(side="right", padx=10, fill="x", expand=True)
        self.entry.bind("", self.on_enter)
        self.entry.bind("", self.on_up)
        self.entry.bind("", self.on_down)
        self.entry.bind("", lambda e: self.hide_menu())

        self.list_frame = tk.Frame(self.root, bg="#2b2b2b")
        self.list_frame.pack(fill="both", expand=True)

        self.search_var.set("")
        self.selected_index = 0
        self.render_items()

        close_btn = tk.Button(self.root, text="× 閉じる (Esc)", bg="#2b2b2b", fg="#888", relief="flat", command=self.hide_menu, font=("Arial", 8))
        close_btn.pack(fill="x", pady=2)

        self.root.update_idletasks()

        if reset_position:
            try:
                mx, my = pyautogui.position()
                screen_w = self.root.winfo_screenwidth()
                screen_h = self.root.winfo_screenheight()

                estimated_h = 70 + (min(len(self.history), 10) * 35)
                menu_h = estimated_h
                menu_w = 420 

                pos_x = mx + 10
                pos_y = my + 10

                if (my > screen_h / 2) or (pos_y + menu_h > screen_h - 60):
                    pos_y = my - menu_h - 10
                
                if pos_x + menu_w > screen_w:
                    pos_x = mx - menu_w - 10
                
                self.root.geometry(f"{menu_w}x{menu_h}+{int(pos_x)}+{int(pos_y)}")
            except:
                self.root.geometry("+100+100")
        
        self.root.deiconify()
        self.root.lift()
        self.root.focus_force()
        self.entry.focus_set()

    def open_ai_chat(self, text):
        self.hide_menu()
        ChatWindow(self.root, text, OLLAMA_MODEL)

    def on_up(self, event):
        if self.selected_index > 0:
            self.selected_index -= 1
            self.render_items()

    def on_down(self, event):
        if self.selected_index < len(self.filtered_history) - 1:
            self.selected_index += 1
            self.render_items()

    def on_enter(self, event):
        if self.filtered_history:
            text = self.filtered_history[self.selected_index]['text']
            self.paste_text(text)

    def toggle_pin_from_filtered(self, item):
        try:
            index = self.history.index(item)
            self.toggle_pin(index)
            self.render_items()
            self.entry.focus_set()
        except:
            pass

    def toggle_pin(self, index):
        if 0 <= index < len(self.history):
            item = self.history[index]
            item['pinned'] = not item['pinned']
            del self.history[index]
            if item['pinned']:
                self.history.insert(0, item)
            else:
                pinned_count = sum(1 for x in self.history if x['pinned'])
                self.history.insert(pinned_count, item)

    def delete_item_from_filtered(self, item):
        try:
            index = self.history.index(item)
            self.delete_item(index)
            self.render_items()
            self.entry.focus_set()
        except:
            pass

    def delete_item(self, index):
        if 0 <= index < len(self.history):
            del self.history[index]

    def hide_menu(self):
        self.root.withdraw()

    def on_focus_out(self, event):
        self.root.after(100, self.check_focus_and_close)

    def check_focus_and_close(self):
        if self.root.focus_displayof() is None:
            self.hide_menu()

    def paste_text(self, text):
        self.hide_menu()
        pyperclip.copy(text)
        self.last_copied = text
        
        if AUTO_PASTE:
            self.remove_hotkey()
            try:
                keyboard.release('alt')
                keyboard.release('v')
            except: pass
            
            time.sleep(0.1)
            try:
                pyautogui.hotkey('ctrl', 'v')
            except: pass

            time.sleep(0.1)
            self.register_hotkey()
        else:
            print("✅ クリップボードにセットしました!")

    def run(self):
        threading.Thread(target=self.run_tray, daemon=True).start()
        self.root.mainloop()

if __name__ == "__main__":
    try:
        hwnd = ctypes.windll.kernel32.GetConsoleWindow()
        if hwnd != 0:
            ctypes.windll.user32.ShowWindow(hwnd, 6)
    except:
        pass

    app = ClipboardManager()
    app.run()

👋 最後に

いやー、長かった!(笑)
でも、自分の手で「最強」を作っていく過程はめちゃくちゃ楽しかったよ!

AI時代になっても、こういう「ちょっとした不便」を自分で解決する力は絶対に無駄にならないからね!
みんなもこのコードをベースに、自分好みに魔改造してみてね!

それじゃ、また次の記事で会おうね!ばいばーい!👋🐍

よかったらシェアしてね!
  • URLをコピーしました!
目次