prototype
This commit is contained in:
commit
8534b15c20
8 changed files with 3048 additions and 0 deletions
566
static/index.html
Normal file
566
static/index.html
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nanobot Chat (SuperTonic + Wisper)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f8fa;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--accent: #0d9488;
|
||||
--border: #d1d5db;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
|
||||
background: linear-gradient(180deg, #eef6ff 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.ptt-active {
|
||||
background: #be123c;
|
||||
color: white;
|
||||
border-color: #be123c;
|
||||
}
|
||||
.log {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
min-height: 420px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: #0b1020;
|
||||
color: #d6e2ff;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.line {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.line.user {
|
||||
color: #9be5ff;
|
||||
}
|
||||
.line.system {
|
||||
color: #ffd28f;
|
||||
}
|
||||
.line.wisper {
|
||||
color: #c4f0be;
|
||||
}
|
||||
|
||||
.voice {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.voice-status {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.controls,
|
||||
.voice {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Nanobot Web Chat (SuperTonic + Wisper)</h1>
|
||||
<div class="panel">
|
||||
<div class="controls">
|
||||
<button id="spawnBtn" class="primary">Spawn Nanobot TUI</button>
|
||||
<button id="stopBtn">Stop TUI</button>
|
||||
</div>
|
||||
<div id="log" class="log"></div>
|
||||
<div class="voice">
|
||||
<button id="recordBtn">Connect Voice Channel</button>
|
||||
<button id="pttBtn" disabled>Hold to Talk</button>
|
||||
<span id="voiceStatus" class="voice-status"></span>
|
||||
</div>
|
||||
<audio id="remoteAudio" autoplay playsinline hidden></audio>
|
||||
<div class="hint">
|
||||
Voice input and output run over a host WebRTC audio channel. Hold Push-to-Talk to send microphone audio for host STT.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logEl = document.getElementById("log");
|
||||
const spawnBtn = document.getElementById("spawnBtn");
|
||||
const stopBtn = document.getElementById("stopBtn");
|
||||
const recordBtn = document.getElementById("recordBtn");
|
||||
const pttBtn = document.getElementById("pttBtn");
|
||||
const voiceStatus = document.getElementById("voiceStatus");
|
||||
const remoteAudio = document.getElementById("remoteAudio");
|
||||
|
||||
const wsProto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${wsProto}://${location.host}/ws/chat`);
|
||||
|
||||
let peerConnection = null;
|
||||
let micStream = null;
|
||||
let remoteStream = null;
|
||||
let voiceConnected = false;
|
||||
let disconnectedTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
let voiceDesired = false;
|
||||
let connectingVoice = false;
|
||||
let pttPressed = false;
|
||||
let rtcAnswerApplied = false;
|
||||
let pendingRemoteCandidates = [];
|
||||
const MAX_RECONNECT_ATTEMPTS = 2;
|
||||
|
||||
const appendLine = (role, text, timestamp) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = `line ${role || "system"}`;
|
||||
const time = timestamp ? new Date(timestamp).toLocaleTimeString() : "";
|
||||
line.textContent = `[${time}] ${role}: ${text}`;
|
||||
logEl.appendChild(line);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
};
|
||||
|
||||
const sendJson = (payload) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
appendLine("system", "Socket not ready.", new Date().toISOString());
|
||||
return;
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
const setVoiceState = (connected) => {
|
||||
voiceConnected = connected;
|
||||
recordBtn.textContent = connected ? "Disconnect Voice Channel" : "Connect Voice Channel";
|
||||
pttBtn.disabled = !connected;
|
||||
if (!connected) {
|
||||
pttBtn.textContent = "Hold to Talk";
|
||||
pttBtn.classList.remove("ptt-active");
|
||||
}
|
||||
};
|
||||
|
||||
const setMicCaptureEnabled = (enabled) => {
|
||||
if (!micStream) return;
|
||||
micStream.getAudioTracks().forEach((track) => {
|
||||
track.enabled = enabled;
|
||||
});
|
||||
};
|
||||
|
||||
const setPushToTalkState = (pressed, notifyServer = true) => {
|
||||
pttPressed = pressed;
|
||||
pttBtn.textContent = pressed ? "Release to Send" : "Hold to Talk";
|
||||
pttBtn.classList.toggle("ptt-active", pressed);
|
||||
setMicCaptureEnabled(pressed);
|
||||
if (notifyServer && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "voice-ptt", pressed }));
|
||||
}
|
||||
};
|
||||
|
||||
const beginPushToTalk = (event) => {
|
||||
if (event) event.preventDefault();
|
||||
if (!voiceConnected || !peerConnection || !micStream) {
|
||||
voiceStatus.textContent = "Connect voice channel first.";
|
||||
return;
|
||||
}
|
||||
if (pttPressed) return;
|
||||
setPushToTalkState(true);
|
||||
voiceStatus.textContent = "Listening while button is held...";
|
||||
};
|
||||
|
||||
const endPushToTalk = (event) => {
|
||||
if (event) event.preventDefault();
|
||||
if (!pttPressed) return;
|
||||
setPushToTalkState(false);
|
||||
if (voiceConnected) {
|
||||
voiceStatus.textContent = "Voice channel connected. Hold Push-to-Talk to speak.";
|
||||
}
|
||||
};
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (reason, delayMs = 1200) => {
|
||||
if (!voiceDesired) return;
|
||||
if (voiceConnected || connectingVoice) return;
|
||||
if (reconnectTimer) return;
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
voiceStatus.textContent = "Voice reconnect attempts exhausted.";
|
||||
return;
|
||||
}
|
||||
reconnectAttempts += 1;
|
||||
voiceStatus.textContent = `${reason} Retrying (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`;
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
reconnectTimer = null;
|
||||
await connectVoiceChannel();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const stopVoiceChannel = async (statusText = "", clearDesired = false) => {
|
||||
if (clearDesired) {
|
||||
voiceDesired = false;
|
||||
reconnectAttempts = 0;
|
||||
clearReconnectTimer();
|
||||
}
|
||||
|
||||
if (disconnectedTimer) {
|
||||
clearTimeout(disconnectedTimer);
|
||||
disconnectedTimer = null;
|
||||
}
|
||||
|
||||
pendingRemoteCandidates = [];
|
||||
rtcAnswerApplied = false;
|
||||
setPushToTalkState(false);
|
||||
|
||||
if (peerConnection) {
|
||||
peerConnection.ontrack = null;
|
||||
peerConnection.onicecandidate = null;
|
||||
peerConnection.onconnectionstatechange = null;
|
||||
peerConnection.close();
|
||||
peerConnection = null;
|
||||
}
|
||||
|
||||
if (micStream) {
|
||||
micStream.getTracks().forEach((track) => track.stop());
|
||||
micStream = null;
|
||||
}
|
||||
|
||||
if (remoteStream) {
|
||||
remoteStream.getTracks().forEach((track) => track.stop());
|
||||
remoteStream = null;
|
||||
}
|
||||
|
||||
remoteAudio.srcObject = null;
|
||||
setVoiceState(false);
|
||||
if (statusText) {
|
||||
voiceStatus.textContent = statusText;
|
||||
}
|
||||
};
|
||||
|
||||
const applyRtcAnswer = async (message) => {
|
||||
if (!peerConnection) return;
|
||||
const rawSdp = (message.sdp || "").toString();
|
||||
if (!rawSdp.trim()) return;
|
||||
const sdp = `${rawSdp
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\r\n")
|
||||
.trim()}\r\n`;
|
||||
try {
|
||||
await peerConnection.setRemoteDescription({
|
||||
type: message.rtcType || "answer",
|
||||
sdp,
|
||||
});
|
||||
rtcAnswerApplied = true;
|
||||
const queued = pendingRemoteCandidates;
|
||||
pendingRemoteCandidates = [];
|
||||
for (const candidate of queued) {
|
||||
try {
|
||||
await peerConnection.addIceCandidate(candidate);
|
||||
} catch (candidateErr) {
|
||||
appendLine("system", `Queued ICE apply error: ${candidateErr}`, new Date().toISOString());
|
||||
}
|
||||
}
|
||||
reconnectAttempts = 0;
|
||||
voiceStatus.textContent = "Voice channel negotiated.";
|
||||
} catch (err) {
|
||||
await stopVoiceChannel("Failed to apply WebRTC answer.");
|
||||
scheduleReconnect("Failed to apply answer.");
|
||||
const preview = sdp
|
||||
.split(/\r\n/)
|
||||
.slice(0, 6)
|
||||
.join(" | ");
|
||||
appendLine(
|
||||
"system",
|
||||
`RTC answer error: ${err}. SDP preview: ${preview}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const applyRtcIceCandidate = async (message) => {
|
||||
if (!peerConnection) return;
|
||||
if (message.candidate == null) {
|
||||
if (!rtcAnswerApplied || !peerConnection.remoteDescription) {
|
||||
pendingRemoteCandidates.push(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await peerConnection.addIceCandidate(null);
|
||||
} catch (err) {
|
||||
appendLine("system", `RTC ICE end error: ${err}`, new Date().toISOString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!rtcAnswerApplied || !peerConnection.remoteDescription) {
|
||||
pendingRemoteCandidates.push(message.candidate);
|
||||
return;
|
||||
}
|
||||
await peerConnection.addIceCandidate(message.candidate);
|
||||
} catch (err) {
|
||||
appendLine("system", `RTC ICE error: ${err}`, new Date().toISOString());
|
||||
}
|
||||
};
|
||||
|
||||
const connectVoiceChannel = async () => {
|
||||
if (voiceConnected || peerConnection || connectingVoice) return;
|
||||
if (!window.RTCPeerConnection) {
|
||||
voiceStatus.textContent = "WebRTC unavailable in this browser.";
|
||||
return;
|
||||
}
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
voiceStatus.textContent = "Microphone capture is unavailable.";
|
||||
return;
|
||||
}
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
voiceStatus.textContent = "Socket not ready yet.";
|
||||
return;
|
||||
}
|
||||
|
||||
connectingVoice = true;
|
||||
try {
|
||||
clearReconnectTimer();
|
||||
rtcAnswerApplied = false;
|
||||
pendingRemoteCandidates = [];
|
||||
|
||||
try {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: 48000,
|
||||
sampleSize: 16,
|
||||
latency: 0,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: false,
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
} catch (_constraintErr) {
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: false,
|
||||
});
|
||||
voiceStatus.textContent = "Using browser default microphone settings.";
|
||||
}
|
||||
setMicCaptureEnabled(false);
|
||||
|
||||
peerConnection = new RTCPeerConnection({
|
||||
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
|
||||
});
|
||||
remoteStream = new MediaStream();
|
||||
remoteAudio.srcObject = remoteStream;
|
||||
|
||||
peerConnection.ontrack = (event) => {
|
||||
if (event.track.kind !== "audio") return;
|
||||
remoteStream.addTrack(event.track);
|
||||
remoteAudio.play().catch(() => {
|
||||
voiceStatus.textContent = "Tap the page once to allow voice playback.";
|
||||
});
|
||||
};
|
||||
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (!event.candidate) {
|
||||
sendJson({ type: "rtc-ice-candidate", candidate: null });
|
||||
return;
|
||||
}
|
||||
sendJson({
|
||||
type: "rtc-ice-candidate",
|
||||
candidate: event.candidate.toJSON(),
|
||||
});
|
||||
};
|
||||
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
const state = peerConnection?.connectionState || "new";
|
||||
if (state === "connected") {
|
||||
if (disconnectedTimer) {
|
||||
clearTimeout(disconnectedTimer);
|
||||
disconnectedTimer = null;
|
||||
}
|
||||
clearReconnectTimer();
|
||||
reconnectAttempts = 0;
|
||||
voiceStatus.textContent = "Voice channel connected. Hold Push-to-Talk to speak.";
|
||||
return;
|
||||
}
|
||||
if (state === "failed" || state === "closed") {
|
||||
stopVoiceChannel(`Voice channel ${state}.`);
|
||||
scheduleReconnect(`Voice channel ${state}.`);
|
||||
return;
|
||||
}
|
||||
if (state === "disconnected") {
|
||||
if (disconnectedTimer) clearTimeout(disconnectedTimer);
|
||||
voiceStatus.textContent = "Voice channel disconnected. Waiting to recover...";
|
||||
disconnectedTimer = setTimeout(() => {
|
||||
if (peerConnection?.connectionState === "disconnected") {
|
||||
stopVoiceChannel("Voice channel disconnected.");
|
||||
scheduleReconnect("Voice channel disconnected.");
|
||||
}
|
||||
}, 8000);
|
||||
return;
|
||||
}
|
||||
voiceStatus.textContent = `Voice channel ${state}...`;
|
||||
};
|
||||
|
||||
micStream.getAudioTracks().forEach((track) => {
|
||||
peerConnection.addTrack(track, micStream);
|
||||
});
|
||||
|
||||
setVoiceState(true);
|
||||
voiceStatus.textContent = "Connecting voice channel...";
|
||||
setPushToTalkState(false);
|
||||
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
sendJson({
|
||||
type: "rtc-offer",
|
||||
sdp: offer.sdp,
|
||||
rtcType: offer.type,
|
||||
});
|
||||
} catch (err) {
|
||||
await stopVoiceChannel("Voice channel setup failed.");
|
||||
scheduleReconnect("Voice setup failed.");
|
||||
appendLine("system", `Voice setup error: ${err}`, new Date().toISOString());
|
||||
} finally {
|
||||
connectingVoice = false;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
appendLine("system", "WebSocket connected.", new Date().toISOString());
|
||||
};
|
||||
ws.onclose = async () => {
|
||||
appendLine("system", "WebSocket disconnected.", new Date().toISOString());
|
||||
await stopVoiceChannel("Voice channel disconnected.", true);
|
||||
};
|
||||
ws.onerror = () => appendLine("system", "WebSocket error.", new Date().toISOString());
|
||||
ws.onmessage = async (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === "rtc-answer") {
|
||||
await applyRtcAnswer(msg);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "rtc-ice-candidate") {
|
||||
await applyRtcIceCandidate(msg);
|
||||
return;
|
||||
}
|
||||
if (msg.type === "rtc-state") {
|
||||
const state = (msg.state || "").toString();
|
||||
if (state) {
|
||||
if (state === "connected") {
|
||||
voiceStatus.textContent = "Voice channel connected. Hold Push-to-Talk to speak.";
|
||||
} else {
|
||||
voiceStatus.textContent = `Voice channel ${state}.`;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (msg.type === "rtc-error") {
|
||||
const text = (msg.message || "Unknown WebRTC error.").toString();
|
||||
voiceStatus.textContent = `Voice error: ${text}`;
|
||||
appendLine("system", `Voice error: ${text}`, new Date().toISOString());
|
||||
await stopVoiceChannel("Voice channel error.");
|
||||
scheduleReconnect("Voice channel error.");
|
||||
return;
|
||||
}
|
||||
|
||||
appendLine(msg.role || "system", msg.text || "", msg.timestamp || "");
|
||||
} catch (_err) {
|
||||
appendLine("system", event.data, new Date().toISOString());
|
||||
}
|
||||
};
|
||||
|
||||
spawnBtn.onclick = () => sendJson({ type: "spawn" });
|
||||
stopBtn.onclick = () => sendJson({ type: "stop" });
|
||||
pttBtn.onpointerdown = (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (pttBtn.setPointerCapture) {
|
||||
pttBtn.setPointerCapture(event.pointerId);
|
||||
}
|
||||
beginPushToTalk(event);
|
||||
};
|
||||
pttBtn.onpointerup = (event) => endPushToTalk(event);
|
||||
pttBtn.onpointercancel = (event) => endPushToTalk(event);
|
||||
pttBtn.onlostpointercapture = (event) => endPushToTalk(event);
|
||||
pttBtn.addEventListener("keydown", (event) => {
|
||||
const isSpace = event.code === "Space" || event.key === " ";
|
||||
if (!isSpace || event.repeat) return;
|
||||
beginPushToTalk(event);
|
||||
});
|
||||
pttBtn.addEventListener("keyup", (event) => {
|
||||
const isSpace = event.code === "Space" || event.key === " ";
|
||||
if (!isSpace) return;
|
||||
endPushToTalk(event);
|
||||
});
|
||||
recordBtn.onclick = async () => {
|
||||
if (voiceConnected || peerConnection || connectingVoice) {
|
||||
await stopVoiceChannel("Voice channel disconnected.", true);
|
||||
return;
|
||||
}
|
||||
voiceDesired = true;
|
||||
reconnectAttempts = 0;
|
||||
await connectVoiceChannel();
|
||||
};
|
||||
document.body.addEventListener("click", () => {
|
||||
if (remoteAudio.srcObject && remoteAudio.paused) {
|
||||
remoteAudio.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
setVoiceState(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue