Perbaiki, saat ada ngrok, kan ada 2 koneksi misalnya dari mobile dan dari server local. Buatkan nanti 2 objek lah.  jika 2 devices. pada kodengan awal masih ada error. Pokoknya itu chatbotnya bisa diakses multiple devices. Buatkan seperti senior programmer! Tuliskan kode lengkap tanpa komentar
main.py
import logging
import threading
import urllib.request
import webbrowser
from fastapi import FastAPI, File, UploadFile, Form, Cookie
from fastapi.responses import HTMLResponse, JSONResponse
from starlette.middleware.cors import CORSMiddleware
from starlette.staticfiles import StaticFiles
from Chatbot import Chatbot
import cv2
import numpy as np
import uvicorn

logging.disable(logging.CRITICAL)


class ServerApp:
    def __init__(self):
        self.app = FastAPI()
        self.chatbot = Chatbot()
        self.ngrok_process = None
        self.public_url = None
        self.setup_routes_and_middlewares()

    def setup_routes_and_middlewares(self):
        self.app.add_middleware(
            CORSMiddleware,
            allow_origins=["*"],
            allow_credentials=True,
            allow_methods=["*"],
            allow_headers=["*"]
        )

        directories = {
            "/app": "app",
            "/assets": "app/assets",
            "/js": "app/js",
            "/temp": "temp",
            "/model/live2d": "model/live2d",
            "/CSS": "app/CSS"
        }
        for mount_point, directory in directories.items():
            self.app.mount(mount_point, StaticFiles(directory=directory), name=mount_point.strip("/"))

        # Define routes
        self.app.get("/")(self.index)
        self.app.get("/api/audio_status")(self.get_audio_status)
        self.app.post("/api/reset_audio_status")(self.reset_audio_status)
        self.app.post("/api/upload_frame")(self.upload_frame)
        self.app.post("/api/upload_audio")(self.upload_audio)
        self.app.post("/api/start_ngrok")(self.start_ngrok)
        self.app.post("/api/stop_ngrok")(self.stop_ngrok)

    async def index(self, ngrok_api_key: str = Cookie(default=None)):
        with open('app/index.html', 'r') as f:
            html_content = f.read()
        headers = {"ngrok_api_key": ngrok_api_key} if ngrok_api_key else {}
        return HTMLResponse(content=html_content, headers=headers)

    async def get_audio_status(self):
        return {"audio_ready": self.chatbot.audio_ready}

    async def reset_audio_status(self):
        self.chatbot.audio_ready = False
        return {"audio_ready": self.chatbot.audio_ready}

    async def upload_frame(self, image: UploadFile = File(...)):
        try:
            image_bytes = await image.read()
            frame = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
            self.chatbot.process_frame(frame)
            return JSONResponse(content={"message": "Frame processed successfully"}, status_code=200)
        except Exception as e:
            logging.error(f"Error processing frame: {e}")
            return JSONResponse(content={"message": "Failed to process frame"}, status_code=500)

    async def upload_audio(self, audio: UploadFile = File(...)):
        try:
            audio_bytes = await audio.read()
            self.chatbot.process_audio(audio_bytes)
            return JSONResponse(content={"message": "Audio processed successfully"}, status_code=200)
        except Exception as e:
            logging.error(f"Error processing audio: {e}")
            return JSONResponse(content={"message": "Failed to process audio"}, status_code=500)

    async def start_ngrok(self, api_key: str = Form(...)):
        if not self.check_internet_connection():
            return JSONResponse(content={"message": "Tidak ada koneksi internet."}, status_code=500)

        if self.ngrok_process:
            return JSONResponse(content={"message": "Ngrok sudah berjalan.", "public_url": self.public_url},
                                status_code=200)

        import ngrok
        ngrok.set_auth_token(api_key)
        self.public_url = ngrok.connect(8000).public_url
        self.ngrok_process = ngrok.get_ngrok_process()
        threading.Thread(target=self.ngrok_process.proc.wait).start()

        return JSONResponse(content={"message": "Ngrok berhasil dimulai.", "public_url": self.public_url},
                            status_code=200)

    async def stop_ngrok(self):
        if self.ngrok_process:
            import ngrok
            ngrok.kill()
            self.ngrok_process = None
            self.public_url = None
            return JSONResponse(content={"message": "Ngrok berhasil dihentikan."}, status_code=200)

        return JSONResponse(content={"message": "Ngrok tidak berjalan."}, status_code=400)

    @staticmethod
    def check_internet_connection():
        try:
            urllib.request.urlopen('https://google.com', timeout=5)
            return True
        except Exception:
            return False

    def run(self):
        threading.Thread(target=self.open_browser).start()
        uvicorn.run(self.app, host="localhost", port=8000)

    @staticmethod
    def open_browser():
        webbrowser.open_new("http://localhost:8000")


if __name__ == "__main__":
    server_app = ServerApp()
    server_app.run()

Chatbot.py
import json
import re
from datetime import datetime
from pathlib import Path
import base64

import cv2
import google.generativeai as genai
from google.generativeai.types import generation_types
from gtts import gTTS
from so_vits_svc_fork.inference.main import infer

from Config import Config


class Chatbot:
    def __init__(self):
        self.config = Config()
        self.api_keys = self._load_api_keys()
        if not self.api_keys:
            raise ValueError("API key list is empty. Please provide at least one API key.")
        self.api_key_index = 0
        self.system_prompt = self._load_system_prompt()
        self.generation_config, self.safety_settings = self._model_config()
        self.model = self._get_text_model()
        self.history = self._load_history()
        self.convo = self._start_conversation()
        self._ensure_system_prompt()
        self.frames = []
        self.audio_ready = False

    def _load_api_keys(self):
        with open(self.config.FILES.API_KEY, 'r') as f:
            keys = [line.strip() for line in f if line.strip()]
            print(f"Loaded {len(keys)} API keys.")
            return keys

    def _load_system_prompt(self):
        with open(self.config.FILES.SYSTEM_PROMPT, 'r') as f:
            return f.read().strip()

    def _model_config(self):
        generation_config = generation_types.GenerationConfig(
            temperature=self.config.TEMPERATURE,
            top_p=self.config.TOP_P,
            top_k=self.config.TOP_K,
            max_output_tokens=self.config.MAX_OUTPUT_TOKENS,
        )
        safety_settings = [{"category": cat, "threshold": self.config.BLOCK_NONE} for cat in
                           self.config.HARM_CATEGORIES]
        return generation_config, safety_settings

    def _mask_api_key(self, key):
        if len(key) > 6:
            return key[:-6] + '*' * 6
        return '*' * len(key)

    def _configure_genai(self):
        current_api_key = self.api_keys[self.api_key_index]
        genai.configure(api_key=current_api_key)
        masked_key = self._mask_api_key(current_api_key)
        print(f"Using API key index: {self.api_key_index} (Key: {masked_key})")

    def _get_model(self, model_name, config):
        self._configure_genai()
        return genai.GenerativeModel(
            model_name=model_name,
            generation_config=config,
            safety_settings=self.safety_settings,
            system_instruction=self.system_prompt
        )

    def _get_text_model(self):
        return self._get_model(self.config.MODEL_NAME, self.generation_config)

    def _load_history(self):
        session_path = Path(self.config.FILES.SESSION)
        if session_path.exists():
            try:
                with session_path.open('r', encoding='utf-8') as f:
                    history = json.load(f)
                    if isinstance(history, list):
                        print(f"Loaded history with {len(history)} messages.")
                        return history
            except json.JSONDecodeError:
                print("Session file corrupted or empty. Starting a new conversation.")
        return []

    def _save_history(self):
        session_path = Path(self.config.FILES.SESSION)
        try:
            with session_path.open('w', encoding='utf-8') as f:
                json.dump(self.history, f, ensure_ascii=False, indent=4)
            print(f"History saved with {len(self.history)} messages.")
        except Exception as e:
            print(f"Failed to save history: {e}")

    def _start_conversation(self):
        return self.model.start_chat(history=self.history)

    def _ensure_system_prompt(self):
        if not self.history:
            try:
                system_message = {"role": "user", "parts": [{"text": self.system_prompt}]}
                self.convo.send_message(system_message)
                self.history.append(system_message)
                self._save_history()
                print("System prompt initialized.")
            except Exception as e:
                print(f"Exception during system prompt initialization: {e}. Retrying...")
                self._rotate_api_key()
                self._ensure_system_prompt()

    def _rotate_api_key(self):
        previous_index = self.api_key_index
        self.api_key_index = (self.api_key_index + 1) % len(self.api_keys)
        if self.api_key_index == previous_index:
            print("All API keys have been exhausted.")
        else:
            print(f"Rotated API key to index {self.api_key_index}.")

    def _handle_response(self, user_input):
        max_attempts = len(self.api_keys)
        for attempt in range(max_attempts):
            try:
                response_chunks = self.convo.send_message(user_input)
                response = "".join(chunk.text for chunk in response_chunks)
                break
            except Exception as e:
                error_message = str(e).lower()
                print(f"Exception occurred: {e}.")
                if '429' in error_message or 'quota' in error_message or 'exhausted' in error_message:
                    print(f"Error 429 detected. Rotating API key. Attempt {attempt + 1}/{max_attempts}")
                    self._rotate_api_key()
                    self.model = self._get_text_model()
                else:
                    print("Non-rate limit error encountered. Retrying with the same API key.")
        else:
            print("All API keys have been exhausted. Failed to send message.")
            return

        response = re.sub(r'\(.*?\)', '', response).strip()
        if response:
            try:
                response_mp3_path = Path(self.config.FILES.RESPONSE_MP3)
                response_mp3_path.unlink(missing_ok=True)
                gTTS(text=response, lang='id').save(response_mp3_path)
                print(f"Saved response audio to {response_mp3_path}")

                infer(
                    input_path=response_mp3_path,
                    output_path=Path(self.config.FILES.RESPONSE_WAV),
                    model_path=Path(self.config.FILES.MODEL_PATH),
                    config_path=Path(self.config.FILES.CONFIG_PATH),
                    max_chunk_seconds=self.config.MAX_CHUNK_SECONDS,
                    device=self.config.DEVICE,
                    speaker="",
                    transpose=7,
                )
                self.audio_ready = True
                print("Audio inference completed.")
            except Exception as e:
                print(f"Failed to process audio: {e}")

        self.history.append(user_input)
        assistant_message = {"role": "assistant", "parts": [{"text": response}]}
        self.history.append(assistant_message)

        self._save_history()

    def process_frame(self, frame):
        self.frames.append(frame)
        print(f"Frame added. Total frames: {len(self.frames)}")

    def process_audio(self, audio_bytes):
        if not self.frames:
            print("No frames to process.")
            return

        try:
            encoded_frames = [
                {
                    "mime_type": "image/jpeg",
                    "data": base64.b64encode(cv2.imencode('.jpg', frame)[1]).decode('utf-8')
                }
                for frame in self.frames
            ]
            print(f"Encoded {len(encoded_frames)} frames.")
        except Exception as e:
            print(f"Failed to encode frames: {e}")
            encoded_frames = []

        try:
            encoded_audio = base64.b64encode(audio_bytes).decode('utf-8')
            print("Audio encoded successfully.")
        except Exception as e:
            print(f"Failed to encode audio: {e}")
            encoded_audio = ""

        user_input = {
            "role": "user",
            "parts": [
                {"text": "Waktu: " + datetime.now().strftime("%Y-%m-%d %H:%M:%S")},
                {"text": "Penglihatan Vixevia:"},
                *encoded_frames,
                {"text": "Pendengaran Vixevia:"},
                {"mime_type": "audio/wav", "data": encoded_audio},
            ],
        }
        self._handle_response(user_input)
        self.frames.clear()
        print("Frames cleared after processing audio.")
index.html
<!DOCTYPE html>
<html lang="id">
<head>
    <title>Vixevia</title>
    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
    <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="CSS/style.css">
    <link rel="icon" href="assets/app.ico">

    <script src="https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/dylanNew/live2d/webgl/Live2D/lib/live2d.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/pixi.js@6.5.2/dist/browser/pixi.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/RaSan147/pixi-live2d-display@v0.4.0-ls-2/dist/cubism4.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.14.0/dist/ort.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/@ricky0123/vad-web@0.0.18/dist/bundle.min.js"></script>
    <style>
        .modal-body {
            max-height: 80vh;
            overflow-y: auto;
        }
    </style>
</head>
<body>
<div id="status-container">
    <div id="status" class="status-bubble animate__animated">Offline</div>
</div>
<div id="canvas-container">
    <canvas id="canvas"></canvas>
</div>

<button type="button" class="btn btn-primary floating-button animate__animated animate__fadeInUp" data-bs-toggle="modal" data-bs-target="#settingsModal">
    <i class="bi bi-gear-fill"></i>
</button>

<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
    <div class="modal-dialog modal-dialog-centered modal-fullscreen-sm-down">
        <div class="modal-content bg-dark text-light">
            <div class="modal-header border-bottom border-secondary">
                <h1 class="modal-title fs-5" id="settingsModalLabel"><i class="bi bi-gear-fill me-2"></i> Settings</h1>
                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                <div class="row">
                    <div class="col-md-4 settings-menu-container">
                        <ul class="nav nav-pills flex-column" id="settings-menu">
                            <li class="nav-item">
                                <a class="nav-link" data-bs-toggle="tab" href="#ngrok-settings"><i class="bi bi-cloud-arrow-up me-2"></i> Ngrok</a>
                            </li>
                            <li class="nav-item">
                                <a class="nav-link" data-bs-toggle="tab" href="#info-settings"><i class="bi bi-info-circle me-2"></i> Information</a>
                            </li>
                        </ul>
                    </div>
                    <div class="col-md-8 settings-content-container">
                        <div class="tab-content" id="settings-content">
                            <div class="tab-pane fade" id="ngrok-settings">
                                <h3><i class="bi bi-cloud-arrow-up me-2"></i> Ngrok Settings</h3>
                                <form id="ngrok-form">
                                    <div class="mb-3">
                                        <label for="api-key" class="form-label">Ngrok API Key:</label>
                                        <input type="text" class="form-control" id="api-key" name="api-key" required>
                                    </div>
                                    <button type="button" class="btn btn-primary" id="start-ngrok"><i class="bi bi-play-fill me-2"></i> Start Ngrok</button>
                                </form>
                                <div id="ngrok-status"></div>
                                <button id="stop-ngrok" class="btn btn-danger mt-3" style="display: none;"><i class="bi bi-stop-fill me-2"></i> Stop Ngrok</button>
                            </div>
                            <div class="tab-pane fade" id="info-settings">
                                <h3 class="text-center"><i class="bi bi-info-circle me-2"></i> V.I.X.E.V.I.A</h3>
                                <div class="text-center mb-3">
                                    <img src="assets/app.png" alt="Logo Aplikasi" class="img-fluid" style="max-height: 100px;">
                                </div>
                                <p class="text-center">Versi 1.0</p>
                                <p>V.I.X.E.V.I.A.</p>
                                <h4>Developer: </h4>
                                <ul>
                                    <li>Mohammad Farid Hendianto </li>
                                </ul>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="js/main.js" type="module"></script>
<script src="js/setup.js" type="module"></script>
</body>
</html>

main.js
class NgrokController {
    constructor() {
        this.floatingButton = document.querySelector(".floating-button");
        this.modal = new bootstrap.Modal(document.getElementById("settingsModal"));
        this.ngrokForm = document.getElementById("ngrok-form");
        this.apiKeyInput = document.getElementById("api-key");
        this.ngrokStatus = document.getElementById("ngrok-status");
        this.startNgrokButton = document.getElementById("start-ngrok");
        this.stopNgrokButton = document.getElementById("stop-ngrok");

        this.initializeEvents();
    }

    initializeEvents() {
        this.floatingButton.addEventListener("click", () => this.modal.show());
        this.startNgrokButton.addEventListener("click", () => this.startNgrok());
        this.stopNgrokButton.addEventListener("click", () => this.stopNgrok());
    }

    async startNgrok() {
        const apiKey = this.apiKeyInput.value;
        try {
            const response = await fetch("/api/start_ngrok", {
                method: "POST",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                },
                body: new URLSearchParams({ api_key: apiKey }),
            });

            const data = await response.json();
            if (response.ok) {
                this.updateNgrokStatus('success', `Ngrok is running. Public URL: <a href="${data.public_url}" target="_blank">${data.public_url}</a>`);
                this.toggleNgrokButtons();
            } else {
                this.updateNgrokStatus('danger', data.message);
            }
        } catch (error) {
            console.error("Error starting ngrok:", error);
            this.updateNgrokStatus('danger', "Error starting ngrok.");
        }
    }

    async stopNgrok() {
        try {
            const response = await fetch("/api/stop_ngrok", { method: "POST" });
            const data = await response.json();
            this.updateNgrokStatus(response.ok ? 'success' : 'danger', data.message);
            if (response.ok) this.toggleNgrokButtons();
        } catch (error) {
            console.error("Error stopping ngrok:", error);
            this.updateNgrokStatus('danger', "Error stopping ngrok.");
        }
    }

    updateNgrokStatus(status, message) {
        this.ngrokStatus.innerHTML = `<div class="alert alert-${status} mt-3" role="alert">${message}</div>`;
    }

    toggleNgrokButtons() {
        this.startNgrokButton.style.display = this.startNgrokButton.style.display === "none" ? "block" : "none";
        this.stopNgrokButton.style.display = this.stopNgrokButton.style.display === "none" ? "block" : "none";
    }
}

document.addEventListener("DOMContentLoaded", () => new NgrokController());

model.js
class ModelController {
    constructor(app, modelPath) {
        this.app = app;
        this.modelPath = modelPath;
        this.model = null;
    }

    async loadModel() {
        this.model = await PIXI.live2d.Live2DModel.from(this.modelPath + "?v=" + new Date().getTime());
        this.app.stage.addChild(this.model);
        this.model.internalModel.coreModel.setParameterValueById('ParamMouthForm', 1);
        window.addEventListener('resize', () => this.resizeModel());
        this.resizeModel();
        return this.model;
    }

    resizeModel() {
        if (!this.model) return;
        const scale = Math.min(window.innerWidth / this.model.width, window.innerHeight / this.model.height);
        this.model.scale.set(scale, scale);
        this.model.anchor.set(0.5, 0.5);
        this.model.position.set(window.innerWidth / 2, window.innerHeight / 2);
    }

    setMouthOpenY(v) {
        v *= 8;
        this.model.internalModel.coreModel.setParameterValueById('ParamMouthOpenY', Math.max(0, Math.min(1, v)));
    }

    blink() {
        let blinkValue = 1;
        const blinkSpeed = 0.1;
        const blink = () => {
            blinkValue -= blinkSpeed;
            this.updateEyeParameters(blinkValue);
            if (blinkValue <= 0) {
                blinkValue = 0;
                setTimeout(() => this.unblink(), 100);
            } else {
                requestAnimationFrame(blink);
            }
        };
        const unblink = () => {
            blinkValue += blinkSpeed;
            this.updateEyeParameters(blinkValue);
            if (blinkValue >= 1) {
                blinkValue = 1;
            } else {
                requestAnimationFrame(unblink);
            }
        };
        blink();
    }

    startBlinking() {
        setInterval(() => this.blink(), Math.random() * 4000 + 2000);
    }

    updateEyeParameters(blinkValue) {
        this.model.internalModel.coreModel.setParameterValueById('ParamEyeLOpen', blinkValue);
        this.model.internalModel.coreModel.setParameterValueById('ParamEyeROpen', blinkValue);
    }
}

export default ModelController;
setup.js
import ModelController from './model.js';

(async () => {
    const app = new PIXI.Application({
        view: document.getElementById("canvas"),
        autoStart: true,
        resizeTo: window
    });
    const modelController = new ModelController(app, "../model/live2d/vixevia.model3.json");
    await modelController.loadModel();
    modelController.startBlinking();

    const statusDiv = document.getElementById('status');
    const audioLink = "/temp/response.wav";
    let audioPlaying = false;
    let isProcessing = false;
    let chunks = [];

    const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
    const video = document.createElement('video');
    video.srcObject = stream;
    video.autoplay = true;
    video.addEventListener('canplay', () => {
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        (function captureFrame() {
            if (video.readyState === 4) {
                canvas.width = video.videoWidth;
                canvas.height = video.videoHeight;
                context.drawImage(video, 0, 0, canvas.width, canvas.height);
                canvas.toBlob(blob => blob && fetch('/api/upload_frame', {
                    method: 'POST',
                    body: new FormData().append('image', blob, 'frame.jpg')
                }), 'image/jpeg');
            }
            setTimeout(captureFrame, 2500);
        })();
    });

    const mediaRecorder = new MediaRecorder(stream);
    mediaRecorder.addEventListener('dataavailable', e => chunks.push(e.data));
    mediaRecorder.addEventListener('stop', () => {
        const blob = new Blob(chunks, { type: 'audio/wav' });
        chunks = [];
        fetch('/api/upload_audio', {
            method: 'POST',
            body: new FormData().append('audio', blob, 'audio.wav')
        }).then(() => {
            statusDiv.textContent = "";
            isProcessing = false;
            (async function playAudioWhenReady() {
                while (!(await fetch('/api/audio_status').then(res => res.json()).then(data => data.audio_ready))) {
                    await new Promise(r => setTimeout(r, 500));
                }
                audioPlaying = true;
                const audio = new Audio(audioLink);
                audio.addEventListener('ended', () => {
                    fetch('/api/reset_audio_status', { method: 'POST' });
                    audioPlaying = false;
                });
                audio.play().then(() => {
                    const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
                    const analyser = audioCtx.createAnalyser();
                    const source = audioCtx.createMediaElementSource(audio);
                    source.connect(analyser);
                    analyser.connect(audioCtx.destination);
                    const dataArray = new Uint8Array(analyser.frequencyBinCount);
                    (function analyseVolume() {
                        analyser.getByteFrequencyData(dataArray);
                        modelController.setMouthOpenY(dataArray.reduce((a, b) => a + b, 0) / dataArray.length / 255);
                        if (!audio.paused) requestAnimationFrame(analyseVolume);
                    })();
                });
            })();
        });
    });

    vad.MicVAD.new({
        onSpeechStart: () => {
            if (!isProcessing && !audioPlaying) {
                mediaRecorder.start();
                statusDiv.textContent = "Listening...";
            }
        },
        onSpeechEnd: () => {
            if (!isProcessing && !audioPlaying) {
                mediaRecorder.stop();
                isProcessing = true;
                statusDiv.textContent = "Processing...";
            }
        }
    }).start();
})();