feat: integrate durable chat voice transport and deployment assets
This commit is contained in:
1 parent
e059660194
commit
523f6cbbf9
14 files changed
+487
-14
No files matched your search
+14
-1
@@ -999,6 +999,13 @@ async function createRoom(token, attempt) {
|
||||
|
||||
let connectionEpoch = 0;
|
||||
let tokenAbort = null;
|
||||
let voiceConversationId = null;
|
||||
function publishVoiceState(active) {
|
||||
window.dispatchEvent(new CustomEvent('hope:voice-state', { detail: { active, conversation_id: voiceConversationId } }));
|
||||
}
|
||||
window.addEventListener('hope:conversation-selected', event => {
|
||||
if ((room || connectionPromise) && event.detail?.conversation_id !== voiceConversationId) stopConversation();
|
||||
});
|
||||
function checkAttempt(attempt) {
|
||||
if (attempt !== connectionEpoch || !HopeAuth.user || HopeAuth.user.status !== "ready") throw new Error("Connection cancelled");
|
||||
}
|
||||
@@ -1021,6 +1028,7 @@ function cleanupRoom() {
|
||||
document.querySelectorAll('[id^="audio-"]').forEach(el => el.remove());
|
||||
document.getElementById("audioUnlockBtn")?.remove();
|
||||
muteBtn.disabled = true; micMuted = false; updateMuteIcon();
|
||||
publishVoiceState(false); voiceConversationId = null;
|
||||
}
|
||||
function stopConversation() {
|
||||
connectionEpoch++; tokenAbort?.abort(); tokenAbort = null; connectionPromise = null;
|
||||
@@ -1034,6 +1042,9 @@ async function connectConversation() {
|
||||
if (connectionPromise) return connectionPromise;
|
||||
if (room && agentReady) return room;
|
||||
const attempt = ++connectionEpoch;
|
||||
if (window.HopeChat?.running) throw new Error('Wait for the current response or stop it before starting voice.');
|
||||
voiceConversationId = window.HopeChat?.conversationId || null;
|
||||
publishVoiceState(true);
|
||||
startBtn.disabled = true; stopBtn.disabled = false;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
@@ -1142,8 +1153,10 @@ function updateMuteIcon() {
|
||||
async function fetchToken(attempt) {
|
||||
checkAttempt(attempt);
|
||||
tokenAbort = new AbortController();
|
||||
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify({}), signal: tokenAbort.signal });
|
||||
const data = await HopeAuth.request("/token", { method: "POST", body: JSON.stringify(voiceConversationId ? { conversation_id: voiceConversationId } : {}), signal: tokenAbort.signal });
|
||||
checkAttempt(attempt);
|
||||
voiceConversationId = data.conversation_id || voiceConversationId;
|
||||
publishVoiceState(true);
|
||||
if (!data.token || typeof data.token !== "string") throw new Error("Token endpoint did not return a room token");
|
||||
return data;
|
||||
}
|
||||
|
||||
+23
-3
@@ -60,6 +60,22 @@ async def token(request: Request):
|
||||
allowed = set(filter(None, os.getenv("CONTROL_ALLOWED_ORIGINS", "").split(",")))
|
||||
if origin not in allowed:
|
||||
return JSONResponse({"error": "Invalid request origin"}, 403, headers=headers)
|
||||
# The conversation identifier is only a request to bind an existing chat;
|
||||
# the trusted account service performs ownership checks before granting it.
|
||||
body_bytes = bytearray()
|
||||
async for chunk in request.stream():
|
||||
body_bytes.extend(chunk)
|
||||
if len(body_bytes) > 4096:
|
||||
return JSONResponse({"error": "Request body too large"}, 413, headers=headers)
|
||||
try:
|
||||
body = json.loads(body_bytes) if body_bytes else {}
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse({"error": "Invalid request"}, 400, headers=headers)
|
||||
conversation_id = body.get("conversation_id")
|
||||
if conversation_id is not None and (not isinstance(conversation_id, str) or not 1 <= len(conversation_id) <= 128):
|
||||
return JSONResponse({"error": "Invalid conversation"}, 400, headers=headers)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
control_headers = {"X-Control-Secret": CONTROL_SECRET}
|
||||
@@ -73,10 +89,14 @@ async def token(request: Request):
|
||||
room_name = f"voice-{user['id']}-{uuid.uuid4().hex}"
|
||||
grant = await client.post(f"{CONTROL_URL}/internal/rooms", headers=control_headers,
|
||||
json={"user_id": user["id"], "room_name": room_name, "ttl": 86400,
|
||||
"session_token": credential})
|
||||
grant.raise_for_status()
|
||||
"session_token": credential, "conversation_id": conversation_id})
|
||||
if grant.status_code != 201:
|
||||
status = grant.status_code if grant.status_code in (401, 403, 404, 409, 503) else 503
|
||||
return JSONResponse({"error": "Conversation unavailable"}, status, headers=headers)
|
||||
granted = grant.json()
|
||||
return JSONResponse({"token": make_token(room_name, f"user:{user['id']}"), "room": room_name,
|
||||
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
||||
"conversation_id": granted.get("conversation_id"),
|
||||
"user": {"id": user["id"], "display_name": user["display_name"]}}, headers=headers)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
return JSONResponse({"error": "Account service unavailable"}, 503, headers=headers)
|
||||
|
||||
|
||||
Reference in new issue
Block a user