Initial commit: AI Radio Show web application
- FastAPI backend with multiple TTS providers (Inworld, ElevenLabs, Kokoro, F5-TTS, etc.) - Web frontend with caller management, music, and soundboard - Whisper transcription integration - OpenRouter/Ollama LLM support - Castopod podcast publishing script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
543
frontend/css/style.css
Normal file
543
frontend/css/style.css
Normal file
@@ -0,0 +1,543 @@
|
||||
/* AI Radio Show - Clean CSS */
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--bg-light: #252547;
|
||||
--accent: #e94560;
|
||||
--text: #fff;
|
||||
--text-muted: #888;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.header-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
header button {
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-session-btn {
|
||||
background: var(--accent) !important;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.caller-background {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.caller-background.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main layout */
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
background: var(--bg-light);
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Callers */
|
||||
.caller-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.caller-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 2px solid transparent;
|
||||
padding: 10px 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.caller-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.caller-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.call-status {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hangup-btn {
|
||||
width: 100%;
|
||||
background: #c0392b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hangup-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
.chat-section {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.chat-section {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-log {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message.host {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.message.caller {
|
||||
background: #553c9a;
|
||||
}
|
||||
|
||||
.message strong {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.talk-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.talk-btn {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.talk-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.talk-btn.recording {
|
||||
background: #c0392b;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.type-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 16px 24px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Music */
|
||||
.music-section select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.music-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.music-controls button {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.music-controls input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Soundboard */
|
||||
.soundboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sound-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 12px 8px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.sound-btn:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.sound-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-light);
|
||||
padding: 24px;
|
||||
border-radius: var(--radius);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
margin: 16px 0 8px 0;
|
||||
border-bottom: 1px solid var(--bg);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.device-row label:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.channel-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.channel-input {
|
||||
width: 50px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-content label {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-content label.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-content select,
|
||||
.modal-content input[type="text"],
|
||||
.modal-content textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-buttons button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-buttons button:first-child {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-buttons button:last-child {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: var(--bg);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--bg-light);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.refresh-btn:hover {
|
||||
background: var(--bg-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Server Log */
|
||||
.log-section {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.log-section {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.server-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.server-btn {
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.server-btn.restart {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.server-btn.restart:hover {
|
||||
background: #1976D2;
|
||||
}
|
||||
|
||||
.server-btn.stop {
|
||||
background: #c0392b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.server-btn.stop:hover {
|
||||
background: #a93226;
|
||||
}
|
||||
|
||||
.auto-scroll-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.server-log {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #0d0d1a;
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: #8f8;
|
||||
}
|
||||
|
||||
.server-log .log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.server-log .log-line.error {
|
||||
color: #f88;
|
||||
}
|
||||
|
||||
.server-log .log-line.warning {
|
||||
color: #ff8;
|
||||
}
|
||||
|
||||
.server-log .log-line.tts {
|
||||
color: #8ff;
|
||||
}
|
||||
|
||||
.server-log .log-line.chat {
|
||||
color: #f8f;
|
||||
}
|
||||
178
frontend/index.html
Normal file
178
frontend/index.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Radio Show</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header>
|
||||
<h1>AI Radio Show</h1>
|
||||
<div class="header-buttons">
|
||||
<button id="new-session-btn" class="new-session-btn">New Session</button>
|
||||
<button id="settings-btn">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Callers -->
|
||||
<section class="callers-section">
|
||||
<h2>Callers <span id="session-id" class="session-id"></span></h2>
|
||||
<div id="callers" class="caller-grid"></div>
|
||||
<div id="call-status" class="call-status">No active call</div>
|
||||
<div id="caller-background" class="caller-background hidden"></div>
|
||||
<button id="hangup-btn" class="hangup-btn" disabled>Hang Up</button>
|
||||
</section>
|
||||
|
||||
<!-- Chat -->
|
||||
<section class="chat-section">
|
||||
<div id="chat" class="chat-log"></div>
|
||||
<div class="talk-controls">
|
||||
<button id="talk-btn" class="talk-btn">Hold to Talk</button>
|
||||
<button id="type-btn" class="type-btn">Type</button>
|
||||
</div>
|
||||
<div id="status" class="status hidden"></div>
|
||||
</section>
|
||||
|
||||
<!-- Music -->
|
||||
<section class="music-section">
|
||||
<h2>Music</h2>
|
||||
<select id="track-select"></select>
|
||||
<div class="music-controls">
|
||||
<button id="play-btn">Play</button>
|
||||
<button id="stop-btn">Stop</button>
|
||||
<input type="range" id="volume" min="0" max="100" value="30">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sound Effects -->
|
||||
<section class="sounds-section">
|
||||
<h2>Sounds</h2>
|
||||
<div id="soundboard" class="soundboard"></div>
|
||||
</section>
|
||||
|
||||
<!-- Server Log -->
|
||||
<section class="log-section">
|
||||
<div class="log-header">
|
||||
<h2>Server Log</h2>
|
||||
<div class="server-controls">
|
||||
<button id="restart-server-btn" class="server-btn restart">Restart</button>
|
||||
<button id="stop-server-btn" class="server-btn stop">Stop</button>
|
||||
<label class="auto-scroll-label">
|
||||
<input type="checkbox" id="auto-scroll" checked> Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="server-log" class="server-log"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2>Settings</h2>
|
||||
|
||||
<!-- Audio Devices -->
|
||||
<div class="settings-group">
|
||||
<h3>Audio Routing</h3>
|
||||
<div class="device-row">
|
||||
<label>
|
||||
Input Device
|
||||
<select id="input-device"></select>
|
||||
</label>
|
||||
<label>
|
||||
Ch
|
||||
<input type="number" id="input-channel" value="1" min="1" max="16" class="channel-input">
|
||||
</label>
|
||||
</div>
|
||||
<div class="device-row">
|
||||
<label>
|
||||
Output Device
|
||||
<select id="output-device"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="channel-row">
|
||||
<label>Caller Ch <input type="number" id="caller-channel" value="1" min="1" max="16" class="channel-input"></label>
|
||||
<label>Music Ch <input type="number" id="music-channel" value="2" min="1" max="16" class="channel-input"></label>
|
||||
<label>SFX Ch <input type="number" id="sfx-channel" value="3" min="1" max="16" class="channel-input"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LLM Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>LLM Provider</h3>
|
||||
<label>
|
||||
Provider
|
||||
<select id="provider">
|
||||
<option value="openrouter">OpenRouter</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div id="openrouter-settings">
|
||||
<label>
|
||||
Model
|
||||
<select id="openrouter-model"></select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ollama-settings" class="hidden">
|
||||
<label>
|
||||
Model
|
||||
<select id="ollama-model"></select>
|
||||
</label>
|
||||
<label>
|
||||
Host
|
||||
<input type="text" id="ollama-host" value="http://localhost:11434">
|
||||
</label>
|
||||
<button type="button" id="refresh-ollama" class="refresh-btn">Refresh Models</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS Settings -->
|
||||
<div class="settings-group">
|
||||
<h3>TTS Provider</h3>
|
||||
<label>
|
||||
Provider
|
||||
<select id="tts-provider">
|
||||
<option value="inworld">Inworld (High quality, natural)</option>
|
||||
<option value="f5tts">F5-TTS (Most natural local)</option>
|
||||
<option value="elevenlabs">ElevenLabs (Best quality, paid)</option>
|
||||
<option value="kokoro">Kokoro MLX (Fast, Apple Silicon)</option>
|
||||
<option value="chattts">ChatTTS (Conversational)</option>
|
||||
<option value="styletts2">StyleTTS2 (Voice cloning)</option>
|
||||
<option value="vits">VITS (Fast local)</option>
|
||||
<option value="bark">Bark (Expressive, supports [laughs])</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="phone-filter">
|
||||
Phone filter on voices
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-buttons">
|
||||
<button id="save-settings">Save</button>
|
||||
<button id="close-settings">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type Modal -->
|
||||
<div id="type-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2>Type Message</h2>
|
||||
<textarea id="type-input" rows="3" placeholder="Type what you want to say..."></textarea>
|
||||
<div class="modal-buttons">
|
||||
<button id="send-type">Send</button>
|
||||
<button id="close-type">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/app.js?v=8"></script>
|
||||
</body>
|
||||
</html>
|
||||
782
frontend/js/app.js
Normal file
782
frontend/js/app.js
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* AI Radio Show - Control Panel (Server-Side Audio)
|
||||
*/
|
||||
|
||||
// --- State ---
|
||||
let currentCaller = null;
|
||||
let isProcessing = false;
|
||||
let isRecording = false;
|
||||
let phoneFilter = false;
|
||||
let autoScroll = true;
|
||||
let logPollInterval = null;
|
||||
let lastLogCount = 0;
|
||||
|
||||
// Track lists
|
||||
let tracks = [];
|
||||
let sounds = [];
|
||||
|
||||
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.log('AI Radio Show initializing...');
|
||||
try {
|
||||
await loadAudioDevices();
|
||||
await loadCallers();
|
||||
await loadMusic();
|
||||
await loadSounds();
|
||||
await loadSettings();
|
||||
initEventListeners();
|
||||
log('Ready. Configure audio devices in Settings, then click a caller to start.');
|
||||
console.log('AI Radio Show ready');
|
||||
} catch (err) {
|
||||
console.error('Init error:', err);
|
||||
log('Error loading: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function initEventListeners() {
|
||||
// Hangup
|
||||
document.getElementById('hangup-btn')?.addEventListener('click', hangup);
|
||||
|
||||
// New Session
|
||||
document.getElementById('new-session-btn')?.addEventListener('click', newSession);
|
||||
|
||||
// Server controls
|
||||
document.getElementById('restart-server-btn')?.addEventListener('click', restartServer);
|
||||
document.getElementById('stop-server-btn')?.addEventListener('click', stopServer);
|
||||
document.getElementById('auto-scroll')?.addEventListener('change', e => {
|
||||
autoScroll = e.target.checked;
|
||||
});
|
||||
|
||||
// Start log polling
|
||||
startLogPolling();
|
||||
|
||||
// Talk button - now triggers server-side recording
|
||||
const talkBtn = document.getElementById('talk-btn');
|
||||
if (talkBtn) {
|
||||
talkBtn.addEventListener('mousedown', startRecording);
|
||||
talkBtn.addEventListener('mouseup', stopRecording);
|
||||
talkBtn.addEventListener('mouseleave', () => { if (isRecording) stopRecording(); });
|
||||
talkBtn.addEventListener('touchstart', e => { e.preventDefault(); startRecording(); });
|
||||
talkBtn.addEventListener('touchend', e => { e.preventDefault(); stopRecording(); });
|
||||
}
|
||||
|
||||
// Type button
|
||||
document.getElementById('type-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('type-modal')?.classList.remove('hidden');
|
||||
document.getElementById('type-input')?.focus();
|
||||
});
|
||||
document.getElementById('send-type')?.addEventListener('click', sendTypedMessage);
|
||||
document.getElementById('close-type')?.addEventListener('click', () => {
|
||||
document.getElementById('type-modal')?.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('type-input')?.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendTypedMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Music - now server-side
|
||||
document.getElementById('play-btn')?.addEventListener('click', playMusic);
|
||||
document.getElementById('stop-btn')?.addEventListener('click', stopMusic);
|
||||
document.getElementById('volume')?.addEventListener('input', setMusicVolume);
|
||||
|
||||
// Settings
|
||||
document.getElementById('settings-btn')?.addEventListener('click', async () => {
|
||||
document.getElementById('settings-modal')?.classList.remove('hidden');
|
||||
await loadSettings(); // Reload settings when modal opens
|
||||
});
|
||||
document.getElementById('close-settings')?.addEventListener('click', () => {
|
||||
document.getElementById('settings-modal')?.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('save-settings')?.addEventListener('click', saveSettings);
|
||||
document.getElementById('provider')?.addEventListener('change', updateProviderUI);
|
||||
document.getElementById('phone-filter')?.addEventListener('change', e => {
|
||||
phoneFilter = e.target.checked;
|
||||
});
|
||||
document.getElementById('refresh-ollama')?.addEventListener('click', refreshOllamaModels);
|
||||
}
|
||||
|
||||
|
||||
async function refreshOllamaModels() {
|
||||
const btn = document.getElementById('refresh-ollama');
|
||||
const select = document.getElementById('ollama-model');
|
||||
if (!select) return;
|
||||
|
||||
btn.textContent = 'Loading...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
|
||||
select.innerHTML = '';
|
||||
const models = data.available_ollama_models || [];
|
||||
|
||||
if (models.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = '';
|
||||
option.textContent = '(No models found)';
|
||||
select.appendChild(option);
|
||||
} else {
|
||||
models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh Ollama models:', err);
|
||||
}
|
||||
|
||||
btn.textContent = 'Refresh Models';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
|
||||
// --- Audio Devices ---
|
||||
async function loadAudioDevices() {
|
||||
try {
|
||||
const res = await fetch('/api/audio/devices');
|
||||
const data = await res.json();
|
||||
|
||||
const inputSelect = document.getElementById('input-device');
|
||||
const outputSelect = document.getElementById('output-device');
|
||||
|
||||
if (!inputSelect || !outputSelect) return;
|
||||
|
||||
// Clear selects
|
||||
inputSelect.innerHTML = '<option value="">-- Select --</option>';
|
||||
outputSelect.innerHTML = '<option value="">-- Select --</option>';
|
||||
|
||||
data.devices.forEach(device => {
|
||||
// Input devices
|
||||
if (device.inputs > 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.id;
|
||||
opt.textContent = `${device.name} (${device.inputs} ch)`;
|
||||
inputSelect.appendChild(opt);
|
||||
}
|
||||
// Output devices
|
||||
if (device.outputs > 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = device.id;
|
||||
opt.textContent = `${device.name} (${device.outputs} ch)`;
|
||||
outputSelect.appendChild(opt);
|
||||
}
|
||||
});
|
||||
|
||||
// Load current settings
|
||||
const settingsRes = await fetch('/api/audio/settings');
|
||||
const settings = await settingsRes.json();
|
||||
|
||||
if (settings.input_device !== null)
|
||||
inputSelect.value = settings.input_device;
|
||||
if (settings.output_device !== null)
|
||||
outputSelect.value = settings.output_device;
|
||||
|
||||
// Channel settings
|
||||
const inputCh = document.getElementById('input-channel');
|
||||
const callerCh = document.getElementById('caller-channel');
|
||||
const musicCh = document.getElementById('music-channel');
|
||||
const sfxCh = document.getElementById('sfx-channel');
|
||||
|
||||
if (inputCh) inputCh.value = settings.input_channel || 1;
|
||||
if (callerCh) callerCh.value = settings.caller_channel || 1;
|
||||
if (musicCh) musicCh.value = settings.music_channel || 2;
|
||||
if (sfxCh) sfxCh.value = settings.sfx_channel || 3;
|
||||
|
||||
// Phone filter setting
|
||||
const phoneFilterEl = document.getElementById('phone-filter');
|
||||
if (phoneFilterEl) {
|
||||
phoneFilterEl.checked = settings.phone_filter ?? false;
|
||||
phoneFilter = phoneFilterEl.checked;
|
||||
}
|
||||
|
||||
console.log('Audio devices loaded');
|
||||
} catch (err) {
|
||||
console.error('loadAudioDevices error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function saveAudioDevices() {
|
||||
const inputDevice = document.getElementById('input-device')?.value;
|
||||
const outputDevice = document.getElementById('output-device')?.value;
|
||||
const inputChannel = document.getElementById('input-channel')?.value;
|
||||
const callerChannel = document.getElementById('caller-channel')?.value;
|
||||
const musicChannel = document.getElementById('music-channel')?.value;
|
||||
const sfxChannel = document.getElementById('sfx-channel')?.value;
|
||||
const phoneFilterChecked = document.getElementById('phone-filter')?.checked ?? false;
|
||||
|
||||
await fetch('/api/audio/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input_device: inputDevice ? parseInt(inputDevice) : null,
|
||||
input_channel: inputChannel ? parseInt(inputChannel) : 1,
|
||||
output_device: outputDevice ? parseInt(outputDevice) : null,
|
||||
caller_channel: callerChannel ? parseInt(callerChannel) : 1,
|
||||
music_channel: musicChannel ? parseInt(musicChannel) : 2,
|
||||
sfx_channel: sfxChannel ? parseInt(sfxChannel) : 3,
|
||||
phone_filter: phoneFilterChecked
|
||||
})
|
||||
});
|
||||
|
||||
// Update local state
|
||||
phoneFilter = phoneFilterChecked;
|
||||
|
||||
log('Audio routing saved');
|
||||
}
|
||||
|
||||
|
||||
// --- Callers ---
|
||||
async function loadCallers() {
|
||||
try {
|
||||
const res = await fetch('/api/callers');
|
||||
const data = await res.json();
|
||||
|
||||
const grid = document.getElementById('callers');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
|
||||
data.callers.forEach(caller => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'caller-btn';
|
||||
btn.textContent = caller.name;
|
||||
btn.dataset.key = caller.key;
|
||||
btn.addEventListener('click', () => startCall(caller.key, caller.name));
|
||||
grid.appendChild(btn);
|
||||
});
|
||||
|
||||
// Show session ID
|
||||
const sessionEl = document.getElementById('session-id');
|
||||
if (sessionEl && data.session_id) {
|
||||
sessionEl.textContent = `(${data.session_id})`;
|
||||
}
|
||||
|
||||
console.log('Loaded', data.callers.length, 'callers, session:', data.session_id);
|
||||
} catch (err) {
|
||||
console.error('loadCallers error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function startCall(key, name) {
|
||||
if (isProcessing) return;
|
||||
|
||||
const res = await fetch(`/api/call/${key}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
currentCaller = { key, name };
|
||||
|
||||
document.getElementById('call-status').textContent = `On call: ${name}`;
|
||||
document.getElementById('hangup-btn').disabled = false;
|
||||
|
||||
// Show caller background
|
||||
const bgEl = document.getElementById('caller-background');
|
||||
if (bgEl && data.background) {
|
||||
bgEl.textContent = data.background;
|
||||
bgEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
document.querySelectorAll('.caller-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.key === key);
|
||||
});
|
||||
|
||||
log(`Connected to ${name}`);
|
||||
clearChat();
|
||||
}
|
||||
|
||||
|
||||
async function newSession() {
|
||||
// Hangup if on a call
|
||||
if (currentCaller) {
|
||||
await hangup();
|
||||
}
|
||||
|
||||
await fetch('/api/session/reset', { method: 'POST' });
|
||||
|
||||
// Hide caller background
|
||||
const bgEl = document.getElementById('caller-background');
|
||||
if (bgEl) bgEl.classList.add('hidden');
|
||||
|
||||
// Reload callers to get new session ID
|
||||
await loadCallers();
|
||||
|
||||
log('New session started - all callers have fresh backgrounds');
|
||||
}
|
||||
|
||||
|
||||
async function hangup() {
|
||||
if (!currentCaller) return;
|
||||
|
||||
// Stop any playing TTS
|
||||
await fetch('/api/tts/stop', { method: 'POST' });
|
||||
await fetch('/api/hangup', { method: 'POST' });
|
||||
|
||||
log(`Hung up on ${currentCaller.name}`);
|
||||
|
||||
currentCaller = null;
|
||||
isProcessing = false;
|
||||
hideStatus();
|
||||
|
||||
document.getElementById('call-status').textContent = 'No active call';
|
||||
document.getElementById('hangup-btn').disabled = true;
|
||||
document.querySelectorAll('.caller-btn').forEach(btn => btn.classList.remove('active'));
|
||||
|
||||
// Hide caller background
|
||||
const bgEl = document.getElementById('caller-background');
|
||||
if (bgEl) bgEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
||||
// --- Server-Side Recording ---
|
||||
async function startRecording() {
|
||||
if (!currentCaller || isProcessing) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/record/start', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
log('Record error: ' + (err.detail || 'Failed to start'));
|
||||
return;
|
||||
}
|
||||
|
||||
isRecording = true;
|
||||
document.getElementById('talk-btn').classList.add('recording');
|
||||
document.getElementById('talk-btn').textContent = 'Recording...';
|
||||
|
||||
} catch (err) {
|
||||
log('Record error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function stopRecording() {
|
||||
if (!isRecording) return;
|
||||
|
||||
document.getElementById('talk-btn').classList.remove('recording');
|
||||
document.getElementById('talk-btn').textContent = 'Hold to Talk';
|
||||
|
||||
isRecording = false;
|
||||
isProcessing = true;
|
||||
showStatus('Processing...');
|
||||
|
||||
try {
|
||||
// Stop recording and get transcription
|
||||
const res = await fetch('/api/record/stop', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.text) {
|
||||
log('(No speech detected)');
|
||||
isProcessing = false;
|
||||
hideStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
addMessage('You', data.text);
|
||||
|
||||
// Chat
|
||||
showStatus(`${currentCaller.name} is thinking...`);
|
||||
|
||||
const chatRes = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: data.text })
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
|
||||
addMessage(chatData.caller, chatData.text);
|
||||
|
||||
// TTS (plays on server) - only if we have text
|
||||
if (chatData.text && chatData.text.trim()) {
|
||||
showStatus(`${currentCaller.name} is speaking...`);
|
||||
|
||||
await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: chatData.text,
|
||||
voice_id: chatData.voice_id,
|
||||
phone_filter: phoneFilter
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
log('Error: ' + err.message);
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
|
||||
async function sendTypedMessage() {
|
||||
const input = document.getElementById('type-input');
|
||||
const text = input.value.trim();
|
||||
if (!text || !currentCaller || isProcessing) return;
|
||||
|
||||
input.value = '';
|
||||
document.getElementById('type-modal').classList.add('hidden');
|
||||
|
||||
isProcessing = true;
|
||||
addMessage('You', text);
|
||||
|
||||
try {
|
||||
showStatus(`${currentCaller.name} is thinking...`);
|
||||
|
||||
const chatRes = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
const chatData = await chatRes.json();
|
||||
|
||||
addMessage(chatData.caller, chatData.text);
|
||||
|
||||
// TTS (plays on server) - only if we have text
|
||||
if (chatData.text && chatData.text.trim()) {
|
||||
showStatus(`${currentCaller.name} is speaking...`);
|
||||
|
||||
await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: chatData.text,
|
||||
voice_id: chatData.voice_id,
|
||||
phone_filter: phoneFilter
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
log('Error: ' + err.message);
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
hideStatus();
|
||||
}
|
||||
|
||||
|
||||
// --- Music (Server-Side) ---
|
||||
async function loadMusic() {
|
||||
try {
|
||||
const res = await fetch('/api/music');
|
||||
const data = await res.json();
|
||||
tracks = data.tracks || [];
|
||||
|
||||
const select = document.getElementById('track-select');
|
||||
if (!select) return;
|
||||
select.innerHTML = '';
|
||||
|
||||
tracks.forEach((track, i) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = track.file;
|
||||
option.textContent = track.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
console.log('Loaded', tracks.length, 'tracks');
|
||||
} catch (err) {
|
||||
console.error('loadMusic error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function playMusic() {
|
||||
const select = document.getElementById('track-select');
|
||||
const track = select?.value;
|
||||
if (!track) return;
|
||||
|
||||
await fetch('/api/music/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track, action: 'play' })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function stopMusic() {
|
||||
await fetch('/api/music/stop', { method: 'POST' });
|
||||
}
|
||||
|
||||
|
||||
async function setMusicVolume(e) {
|
||||
const volume = e.target.value / 100;
|
||||
await fetch('/api/music/volume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track: '', action: 'volume', volume })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- Sound Effects (Server-Side) ---
|
||||
async function loadSounds() {
|
||||
try {
|
||||
const res = await fetch('/api/sounds');
|
||||
const data = await res.json();
|
||||
sounds = data.sounds || [];
|
||||
|
||||
const board = document.getElementById('soundboard');
|
||||
if (!board) return;
|
||||
board.innerHTML = '';
|
||||
|
||||
sounds.forEach(sound => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'sound-btn';
|
||||
btn.textContent = sound.name;
|
||||
btn.addEventListener('click', () => playSFX(sound.file));
|
||||
board.appendChild(btn);
|
||||
});
|
||||
console.log('Loaded', sounds.length, 'sounds');
|
||||
} catch (err) {
|
||||
console.error('loadSounds error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function playSFX(soundFile) {
|
||||
await fetch('/api/sfx/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sound: soundFile })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- Settings ---
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const res = await fetch('/api/settings');
|
||||
const data = await res.json();
|
||||
|
||||
const providerEl = document.getElementById('provider');
|
||||
if (providerEl) providerEl.value = data.provider || 'openrouter';
|
||||
|
||||
const modelSelect = document.getElementById('openrouter-model');
|
||||
if (modelSelect) {
|
||||
modelSelect.innerHTML = '';
|
||||
(data.available_openrouter_models || []).forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
if (model === data.openrouter_model) option.selected = true;
|
||||
modelSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
const ollamaModel = document.getElementById('ollama-model');
|
||||
const ollamaHost = document.getElementById('ollama-host');
|
||||
if (ollamaHost) ollamaHost.value = data.ollama_host || 'http://localhost:11434';
|
||||
|
||||
// Populate Ollama models dropdown
|
||||
if (ollamaModel) {
|
||||
ollamaModel.innerHTML = '';
|
||||
const ollamaModels = data.available_ollama_models || [];
|
||||
console.log('Ollama models from API:', ollamaModels.length, ollamaModels);
|
||||
if (ollamaModels.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = data.ollama_model || 'llama3.2';
|
||||
option.textContent = data.ollama_model || 'llama3.2';
|
||||
ollamaModel.appendChild(option);
|
||||
} else {
|
||||
ollamaModels.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
if (model === data.ollama_model) option.selected = true;
|
||||
ollamaModel.appendChild(option);
|
||||
});
|
||||
}
|
||||
console.log('Ollama dropdown options:', ollamaModel.options.length);
|
||||
} else {
|
||||
console.log('Ollama model element not found!');
|
||||
}
|
||||
|
||||
// TTS provider
|
||||
const ttsProvider = document.getElementById('tts-provider');
|
||||
if (ttsProvider) ttsProvider.value = data.tts_provider || 'elevenlabs';
|
||||
|
||||
updateProviderUI();
|
||||
console.log('Settings loaded:', data.provider, 'TTS:', data.tts_provider);
|
||||
} catch (err) {
|
||||
console.error('loadSettings error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function updateProviderUI() {
|
||||
const isOpenRouter = document.getElementById('provider')?.value === 'openrouter';
|
||||
document.getElementById('openrouter-settings')?.classList.toggle('hidden', !isOpenRouter);
|
||||
document.getElementById('ollama-settings')?.classList.toggle('hidden', isOpenRouter);
|
||||
}
|
||||
|
||||
|
||||
async function saveSettings() {
|
||||
// Save audio devices
|
||||
await saveAudioDevices();
|
||||
|
||||
// Save LLM and TTS settings
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider: document.getElementById('provider')?.value,
|
||||
openrouter_model: document.getElementById('openrouter-model')?.value,
|
||||
ollama_model: document.getElementById('ollama-model')?.value,
|
||||
ollama_host: document.getElementById('ollama-host')?.value,
|
||||
tts_provider: document.getElementById('tts-provider')?.value
|
||||
})
|
||||
});
|
||||
|
||||
document.getElementById('settings-modal')?.classList.add('hidden');
|
||||
log('Settings saved');
|
||||
}
|
||||
|
||||
|
||||
// --- UI Helpers ---
|
||||
function addMessage(sender, text) {
|
||||
const chat = document.getElementById('chat');
|
||||
if (!chat) {
|
||||
console.log(`[${sender}]: ${text}`);
|
||||
return;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${sender === 'You' ? 'host' : 'caller'}`;
|
||||
div.innerHTML = `<strong>${sender}:</strong> ${text}`;
|
||||
chat.appendChild(div);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
|
||||
|
||||
function clearChat() {
|
||||
const chat = document.getElementById('chat');
|
||||
if (chat) chat.innerHTML = '';
|
||||
}
|
||||
|
||||
|
||||
function log(text) {
|
||||
addMessage('System', text);
|
||||
}
|
||||
|
||||
|
||||
function showStatus(text) {
|
||||
const status = document.getElementById('status');
|
||||
if (status) {
|
||||
status.textContent = text;
|
||||
status.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function hideStatus() {
|
||||
const status = document.getElementById('status');
|
||||
if (status) status.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
||||
// --- Server Control & Logging ---
|
||||
|
||||
function startLogPolling() {
|
||||
// Poll for logs every second
|
||||
logPollInterval = setInterval(fetchLogs, 1000);
|
||||
// Initial fetch
|
||||
fetchLogs();
|
||||
}
|
||||
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const res = await fetch('/api/logs?lines=200');
|
||||
const data = await res.json();
|
||||
|
||||
const logEl = document.getElementById('server-log');
|
||||
if (!logEl) return;
|
||||
|
||||
// Only update if we have new logs
|
||||
if (data.logs.length !== lastLogCount) {
|
||||
lastLogCount = data.logs.length;
|
||||
|
||||
logEl.innerHTML = data.logs.map(line => {
|
||||
let className = 'log-line';
|
||||
if (line.includes('Error') || line.includes('error') || line.includes('ERROR')) {
|
||||
className += ' error';
|
||||
} else if (line.includes('Warning') || line.includes('WARNING')) {
|
||||
className += ' warning';
|
||||
} else if (line.includes('[TTS]')) {
|
||||
className += ' tts';
|
||||
} else if (line.includes('[Chat]')) {
|
||||
className += ' chat';
|
||||
}
|
||||
return `<div class="${className}">${escapeHtml(line)}</div>`;
|
||||
}).join('');
|
||||
|
||||
if (autoScroll) {
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Server might be down, that's ok
|
||||
console.log('Log fetch failed (server may be restarting)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
async function restartServer() {
|
||||
if (!confirm('Restart the server? This will briefly disconnect you.')) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/server/restart', { method: 'POST' });
|
||||
log('Server restart requested...');
|
||||
|
||||
// Clear the log and wait for server to come back
|
||||
document.getElementById('server-log').innerHTML = '<div class="log-line">Restarting server...</div>';
|
||||
|
||||
// Poll until server is back
|
||||
let attempts = 0;
|
||||
const checkServer = setInterval(async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const res = await fetch('/api/server/status');
|
||||
if (res.ok) {
|
||||
clearInterval(checkServer);
|
||||
log('Server restarted successfully');
|
||||
await loadSettings();
|
||||
}
|
||||
} catch (e) {
|
||||
if (attempts > 30) {
|
||||
clearInterval(checkServer);
|
||||
log('Server did not restart - check terminal');
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
} catch (err) {
|
||||
log('Failed to restart server: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function stopServer() {
|
||||
if (!confirm('Stop the server? You will need to restart it manually.')) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/server/stop', { method: 'POST' });
|
||||
log('Server stop requested...');
|
||||
document.getElementById('server-log').innerHTML = '<div class="log-line">Server stopped. Run ./run.sh to restart.</div>';
|
||||
} catch (err) {
|
||||
log('Failed to stop server: ' + err.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user