React + Electron + Python で作る！会話が可能な萌えキャラデスクトップAIアシスタント

## はじめに
初めましての人もそうでない人もこんにちは！

この間、アニメのさくら荘のペットな彼女をみて涙腺が決壊してしまいました

あまりネタバレはよろしくないですが病人に食べさせるものじゃないでしょあれw
ですが、ぜひ多くの人に見て欲しいアニメの一つです！(布教したくなるオタクの性)

少し長くなりましたがその作品にはパソコンの中から色々サポートしてくれるAIがいます！

あーゆーの正直めちゃくちゃ欲しいんですねー
なので作っていこうと思います！(強引)

### 注意：今回記事には画像生成AIの要素があります。苦手な方はなるべくみない様に努力をお願いします。

## 欲しい機能
### 1. 文章生成機能
最重項目です！前みたいにGeminiで作成してみてもいいですけど今回は最近知った「Dify」を使ってみたいと思います！

### 2. 可愛い女の子を表示させる
やっぱりね可愛いは正義なんですよ！パソコンとかネットの世界にいる時ぐらい可愛い女の子を見ていたいです！

### 3. 可愛いボイス
皆さんは可愛い女の子の画像だけで満足できますか？できないでしょう！！
なのでボイスを追加させます！VOICEVOXとかが使いやすい気がする・・・

### 4. ドラッグで移動する機能
正直移動させること自体はそんなに入りません。
理解してもらえるかわかりませんがドラッグしている最中は触っている感じがなんかしません？つまり女の子をドラッグする=女の子に触っているという方程式ができます！

## 使用技術
### React + TypeScript
言わずもがな最強コンビ！俺お前たちなしじゃ開発できんよ！

### Electron
デスクトップアプリを開発するために必要！

### Python
LLMの開発からVOICEVOXを使った開発まで情報がかなり多いので開発しやすい！

## ディレクトリ構成
```
electronapp/
├── build/
│    └── electron/
│         ├── electron.js
│         ├── electron.js.map
│         ├── preload.js
│         └── preload.js.map
├── electron/
│   ├── electron.ts
│   ├── preload.ts
│   └── tsconfig.json
├── src/
│   ├── .env
│   ├── App.css
│   ├── app.py
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── kawaii.png
│   ├── logo.svg
│   ├── venv/
│   ├── reportWebVitals.ts
│   ├── setupTests.ts
│   ├── sky.png
│   └── tsconfig.json
├── node_modules/
├── package-lock.json
├── package.json
├── public/
├── README.md
└── assets/
```

## 環境構築

今回環境を構築するにあたって以下の記事を参考にしました！
こちらの記事の指示通りにやってみてください！

https://qiita.com/m_ina/items/4e86833e22c0a5bde9bb

### DifyAPIを取得
APIを取得する方法については以下の記事を参考にしてみてください！

https://qiita.com/minorun365/items/4c5dba1de7977c386249


### 追加・変更
まずvenv環境を構築してもらいます！
electronapp/srcディレクトリに venv環境を構築します！
```
python3 -m venv venv
source venv/bin/activate
```

次に先ほどのディレクトリ内に以下のコマンドをターミナルにコピペしてください！
```
pip install flask
pip install flask_cors
pip install requests
pip install logging
pip install dotenv
pip install os
pip install sounddevice
pip install json
pip install numpy
```

次にelectron.tsを以下の様に変更してください！
全文コピペで大丈夫です！
```electron.ts
import { app, BrowserWindow } from "electron";
import * as path from "path";
import * as url from "url";

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    resizable: false,
    maximizable: false,
    frame: false,
    transparent: true,
    webPreferences: {
      preload: path.join(__dirname, "preload.js"),
    },
  });

  const appURL = app.isPackaged
    ? url.format({
        pathname: path.join(__dirname, "../index.html"),
        protocol: "file:",
        slashes: true,
      })
    : "http://localhost:3000";

  win.loadURL(appURL);

  if (!app.isPackaged) {
    win.webContents.openDevTools();
  }
};

app.on("window-all-closed", () => {
  if (process.platform !== "darwin") app.quit();
});

app.whenReady().then(() => {
  createWindow();
  app.on("activate", () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});
```

### VOICEVOXのインストール
以下のURLにアクセスしてVOICEVOXをインストールしてアプリを立ち上げておいてください！

https://voicevox.hiroshiba.jp/

### 好きなキャラクター画像を用意
次にデフォルト画像とドラッグした時の画像の2種類を用意してください！
私は絵を描くのが苦手なので生成AIにお願いして作成しました！
kawaii.png
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/3ca0312a-c075-668e-68f8-ed043e5db6fc.png)

sky.png
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/9507606b-af16-b53f-3738-6f29a61379a7.png)

### .envファイルを作成する
electronapp/srcのディレクトリに.envファイルを作成して以下のコードを入力してください！
```src/.env
DIFY_API_KEY=あなたのDify_APIキーを入れてください
DIFY_API_URL=あなたのDify_BaseURLを入れてください
```

## 作ってみよう
### バックエンド
```electronapp/src/app.py
from flask import Flask, request, jsonify
from flask_cors import CORS
import requests
import logging
from dotenv import load_dotenv
import os
import sounddevice as sd
import json
import numpy as np

load_dotenv()

app = Flask(__name__)
CORS(app, resources={r"/*": {"origins": "http://localhost:3000"}})

logging.basicConfig(level=logging.DEBUG)

DIFY_API_KEY = os.getenv('DIFY_API_KEY')
DIFY_API_URL = os.getenv('DIFY_API_URL')

host = "127.0.0.1"
port = "50021"
speaker = 4

def post_audio_query(text: str) -> dict:
    params = {"text": text, "speaker": speaker}
    res = requests.post(
        f"http://{host}:{port}/audio_query",
        params=params,
    )
    query_data = res.json()
    return query_data

def post_synthesis(query_data: dict) -> bytes:
    params = {"speaker": speaker}
    headers = {"content-type": "application/json"}
    res = requests.post(
        f"http://{host}:{port}/synthesis",
        data=json.dumps(query_data),
        params=params,
        headers=headers,
    )
    return res.content

def play_wavfile(wav_data: bytes):
    sample_rate = 24000
    wav_array = np.frombuffer(wav_data, dtype=np.int16)
    sd.play(wav_array, sample_rate, blocking=True)

@app.route('/chat', methods=['POST', 'OPTIONS'])
def chat():
    if request.method == 'OPTIONS':
        response = app.make_default_options_response()
        response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
        return response

    app.logger.debug(f"Received request: {request.json}")

    try:
        data = request.json
        message = data['message']

        headers = {
            'Authorization': f'Bearer {DIFY_API_KEY}',
            'Content-Type': 'application/json'
        }

        payload = {
            'inputs': {},
            'query': message,
            'response_mode': 'blocking',
            'conversation_id': '',
            'user': 'user'
        }

        response = requests.post(DIFY_API_URL, json=payload, headers=headers)
        response.raise_for_status()
        app.logger.debug(f"Dify API response: {response.text}")
        dify_response = response.json()

        answer = dify_response['answer']

        query_data = post_audio_query(answer)
        wav_data = post_synthesis(query_data)
        play_wavfile(wav_data)

        return jsonify({'response': answer})
    except requests.exceptions.RequestException as e:
        app.logger.error(f"Error calling Dify API: {e}")
        return jsonify({'error': f'Failed to get response from Dify API: {str(e)}'}), 500
    except KeyError as e:
        app.logger.error(f"KeyError: {e}")
        return jsonify({'error': f'Invalid response format from Dify API: {str(e)}'}), 500
    except Exception as e:
        app.logger.error(f"Unexpected error: {e}")
        return jsonify({'error': f'An unexpected error occurred: {str(e)}'}), 500

if __name__ == '__main__':
    app.run(debug=True)
```

以下のコードをコピペしたら
```
python3 app.py
```
と入力してサーバーを立ち上げてください！

### フロントエンド
次にsrc/app.tsxを以下の様にコピペしてください！
```src/app.tsx
import React, { useState, useRef } from 'react';
import './App.css';
import dragImage from './sky.png';
import aaImage from './kawaii.png';

function App() {
  const [showOverlay, setShowOverlay] = useState(false);
  const [input, setInput] = useState('');
  const [response, setResponse] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [showInput, setShowInput] = useState(true);
  const [dragging, setDragging] = useState(false);
  const [dragStartX, setDragStartX] = useState(0);
  const [dragStartY, setDragStartY] = useState(0);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isMoveMode, setIsMoveMode] = useState(false);

  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);

  const handleImageClick = () => {
    if (!isMoveMode && !dragging) {
      setIsMoveMode(true);
    }
    setShowOverlay(!showOverlay);
    if (!showOverlay) {
      setInput('');
      setResponse('');
      setShowInput(true);
    }
  };

  const handleOverlayClick = (e: React.MouseEvent) => {
    e.stopPropagation();
  };

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value);
  };

  const handleSubmit = async () => {
    if (!input.trim()) return;

    setIsLoading(true);
    try {
      const res = await fetch('http://127.0.0.1:5000/chat', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ message: input }),
      });
      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`);
      }
      const data = await res.json();
      setResponse(data.response);
      setShowInput(false);
    } catch (error) {
      console.error('Error details:', error);
      setResponse(`Error: ${(error as Error).message}`);
    } finally {
      setIsLoading(false);
    }
  };

  const handleReset = () => {
    setInput('');
    setResponse('');
    setShowInput(true);
  };

  const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
    const target = e.target as HTMLElement; // HTMLElementにキャスト
    if (
      target === inputRef.current ||
      target === buttonRef.current ||
      (target.className === 'black-block' && target.tagName === 'DIV') // 黒いブロックに反応しないようにする
    ) {
      return;
    }
    setDragging(true);
    setDragStartX(e.clientX - position.x);
    setDragStartY(e.clientY - position.y);
  };

  const handleDrag = (e: React.DragEvent<HTMLDivElement>) => {
    if (dragging) {
      const newX = e.clientX - dragStartX;
      const newY = e.clientY - dragStartY;
      setPosition({ x: newX, y: newY });
    }
  };

  const handleDragEnd = () => {
    setDragging(false);
    setIsMoveMode(false);
  };

  return (
    <div className="App">
      <div
        className="image-container"
        onMouseDown={handleDragStart}
        onMouseMove={handleDrag}
        onMouseUp={handleDragEnd}
        onMouseLeave={handleDragEnd}
        onClick={handleImageClick}
        style={{
          position: 'relative',
          left: position.x,
          top: position.y,
          cursor: isMoveMode || dragging ? 'move' : 'auto',
        }}
      >
        {showOverlay && (
          <div className="overlay" onClick={handleOverlayClick}>
            {showInput ? (
              <div className="input-container">
                <input
                  ref={inputRef}
                  type="text"
                  placeholder="Enter text"
                  value={input}
                  onChange={handleInputChange}
                  disabled={isLoading}
                />
                <button ref={buttonRef} onClick={handleSubmit} disabled={isLoading}>
                  {isLoading ? 'Loading...' : 'Submit'}
                </button>
              </div>
            ) : (
              <div className="response-container">
                <div className="response-text">
                  <p>{response}</p>
                </div>
                <button onClick={handleReset}>もう一度話す</button>
              </div>
            )}
          </div>
        )}
        {dragging ? (
          <img
            src={dragImage}
            className="App-image"
            alt="Example"
            draggable="false"
          />
        ) : (
          <img src={aaImage} className="App-image" alt="Example" />
        )}
      </div>
      <div className="black-block" />
    </div>
  );
}

export default App;
```

これを入力したらターミナルをもう一つ開いて以下のコマンドを入力してください！
```
npm run electron:dev
```

これでデスクトップ画面で可愛い女の子と会話ができるはずです！

## 実行すると
可愛い女の子がデスクトップ画面に出てきました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/52758669-92b6-3f09-0733-eab25d46e302.png)

試しに挨拶をしてみます！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/0391a76a-d51c-90a5-f1b1-21f2a9b16b14.png)

少しわかりにくいですがちゃんと挨拶を返してくれました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/204acbe9-7a99-be15-8550-998a3dcd882a.png)


ドラッグして動かすこともできます！かわいい！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631203/df8c48a6-ec98-a9f2-0025-b2d6e226044a.png)

しかもこれ写真なので伝わりにくいですがVOICEVOXのAPIを引っ張ってきているため声も聞こえます！確かめたんって子の声が聞こえています！

## 終わりに
今回はデスクトップ上で可愛い女の子と喋ることができるデスクトップアプリを作成しました！
やはりこのようにパソコンでサポートするAIってロマンがありますよね！
こんどは秘書のように予定管理ができるようにしたいですね！

それではまたどこかの記事でお会いしましょう！

## GithubURL

https://github.com/FrohleinYoshie/desktop_chatbot