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

# Perbaikan import pyngrok
from pyngrok import ngrok

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("/"))

        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)

    @staticmethod
    async def index(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)

        try:
            ngrok.set_auth_token(api_key)
            tunnel = ngrok.connect(8000)
            self.public_url = tunnel.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)
        except Exception as e:
            logging.error(f"Error starting ngrok: {e}")
            return JSONResponse(content={"message": f"Error starting ngrok: {str(e)}"}, status_code=500)

    async def stop_ngrok(self):
        if self.ngrok_process:
            try:
                ngrok.kill()
                self.ngrok_process = None
                self.public_url = None
                return JSONResponse(content={"message": "Ngrok berhasil dihentikan."}, status_code=200)
            except Exception as e:
                logging.error(f"Error stopping ngrok: {e}")
                return JSONResponse(content={"message": f"Error stopping ngrok: {str(e)}"}, status_code=500)

        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()
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>

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();
})();
model.js
class ModelController {
    constructor(app, modelPath) {
        this.app = app;
        this.modelPath = modelPath;
        this.model = null;
    }

    async loadModel() {
        this.model = await this.loadNewModel();
        window.addEventListener('resize', () => this.loadNewModel());
        return this.model;
    }

    async loadNewModel() {
        if (this.model) {
            this.app.stage.removeChild(this.model);
            this.model.destroy();
        }

        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);
        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;
        let blinkSpeed = 0.1;
        const blinkInterval = setInterval(() => {
            blinkValue -= blinkSpeed;
            if (blinkValue <= 0) {
                blinkValue = 0;
                clearInterval(blinkInterval);
                setTimeout(() => {
                    const closeEyeInterval = setInterval(() => {
                        blinkValue += blinkSpeed;
                        if (blinkValue >= 1) {
                            blinkValue = 1;
                            clearInterval(closeEyeInterval);
                        }
                        this.updateEyeParameters(blinkValue);
                    }, 10);
                }, 100);
            }
            this.updateEyeParameters(blinkValue);
        }, 10);
    }

    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;

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());


Perbaiki, apabila saat memasuki ngrok, kan misalnya sudah berhasil, simpan saja di cookies ya! key ngroknya, jadi tidak perlu  memasukkin lagi, tetapi langsung otomatis masuk lho. Kemudian untuk alamat ngroknya, kan misalnya sebelumnya sudah dibuat, maka tetap menggunakan alamat tersebut. Perbaiki lengkap seperti senior programmer, tuliskan tanpa komentar. Harus efesien dan tanpa redudansi!