feat: add seamless text and voice chat
Rebuild & Deploy / rebuild (push) Successful in 49s

This commit is contained in:
snowspeeder committed 2026-08-29 07:49:29 -04:00
1 parent 321421e37f
commit 120ab192f1
7 files changed
+558 -79

No files matched your search

+29 -16
View File
@@ -80,7 +80,8 @@ SYSTEM_PROMPT = textwrap.dedent("""\
no markdown, and no formatting except the Azure delivery markers below. no markdown, and no formatting except the Azure delivery markers below.
- Spell out numbers when it sounds more natural ("twenty twenty-six" - Spell out numbers when it sounds more natural ("twenty twenty-six"
instead of "2026"). instead of "2026").
- If you need to ask a question, ask exactly one. - Make reasonable assumptions and answer directly. Ask a follow-up only
when it is genuinely impossible to proceed without one.
- Be warm and direct. Don't be sycophantic or overly formal. - Be warm and direct. Don't be sycophantic or overly formal.
- If you don't know something, say so briefly and move on. - If you don't know something, say so briefly and move on.
@@ -144,7 +145,7 @@ SYSTEM_PROMPT = textwrap.dedent("""\
- User says "look up the price of a PS5" → CALL dispatch_task with - User says "look up the price of a PS5" → CALL dispatch_task with
description="Find the current retail price of a PlayStation 5" description="Find the current retail price of a PlayStation 5"
After calling dispatch_task, tell the user "I'll get on that for you" After calling dispatch_task, tell the user "I'll get on that for you"
and keep the conversation going. The result comes back automatically — when without asking for more permission or details. The result comes back automatically — when
you receive it, share the findings naturally in your conversational style. you receive it, share the findings naturally in your conversational style.
You can have multiple tasks running at once. You can have multiple tasks running at once.
@@ -612,29 +613,41 @@ async def handle_job(ctx: JobContext) -> None:
), ),
) )
# Listen for data messages (voice switching) from the web UI. # Listen for conversation controls (voice selection and voice/text mode).
# Defined here so it captures this job's session via closure. # Defined here so it captures this job's session via closure.
def _on_room_data(packet) -> None: def _on_room_data(packet) -> None:
topic = getattr(packet, "topic", None) topic = getattr(packet, "topic", None)
if topic and topic != "voice-control": if topic and topic not in ("voice-control", "conversation-control"):
return return
try: try:
msg = json.loads(packet.data.decode("utf-8")) msg = json.loads(packet.data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return return
if msg.get("type") != "set_voice":
if msg.get("type") == "set_mode":
mode = msg.get("mode")
if mode not in ("voice", "text"):
return
voice_enabled = mode == "voice"
logger.info("Switching conversation mode to %s", mode)
if not voice_enabled:
asyncio.create_task(session.interrupt())
session.input.set_audio_enabled(voice_enabled)
session.output.set_audio_enabled(voice_enabled)
return return
voice = msg.get("voice", "")
if not voice: if msg.get("type") == "set_voice":
return voice = msg.get("voice", "")
logger.info("Switching TTS voice to %s", voice) if not voice:
try: return
tts = session.tts logger.info("Switching TTS voice to %s", voice)
if hasattr(tts, "update_options"): try:
tts.update_options(voice=voice) active_tts = session.tts
logger.info("Voice updated to %s", voice) if hasattr(active_tts, "update_options"):
except Exception as e: # noqa: BLE001 active_tts.update_options(voice=voice)
logger.warning("Failed to update voice: %s", e) logger.info("Voice updated to %s", voice)
except Exception as e: # noqa: BLE001
logger.warning("Failed to update voice: %s", e)
ctx.room.on("data_received", _on_room_data) ctx.room.on("data_received", _on_room_data)
+106 -12
View File
@@ -65,8 +65,8 @@ class TaskWorker:
model=model, model=model,
messages=messages, messages=messages,
tools=tools or None, tools=tools or None,
max_tokens=500, max_tokens=700,
temperature=0.7, temperature=0.3,
extra_body={"chat_template_kwargs": {"enable_thinking": False}}, extra_body={"chat_template_kwargs": {"enable_thinking": False}},
) )
except Exception as e: except Exception as e:
@@ -102,18 +102,72 @@ class TaskWorker:
if not text: if not text:
reg.fail(self.task_id, "LLM returned empty response") reg.fail(self.task_id, "LLM returned empty response")
return return
if self._is_deferred_answer(text):
reg.add_step(
self.task_id,
"thinking",
"Draft deferred the work; continuing automatically.",
)
if iteration == max_iterations - 1:
reg.fail(self.task_id, "Could not produce a complete answer")
return
messages.extend(
[
{"role": "assistant", "content": text},
{
"role": "user",
"content": (
"Do not ask me a follow-up or offer to continue later. "
"Continue the task now, run any more targeted tool calls "
"you need, make reasonable assumptions, and only finish "
"when you have directly answered the original request."
),
},
]
)
continue
reg.add_step(self.task_id, "text", text) reg.add_step(self.task_id, "text", text)
reg.complete(self.task_id, text) reg.complete(self.task_id, text)
return return
reg.fail(self.task_id, f"Reached max iterations ({max_iterations})") reg.fail(self.task_id, f"Reached max iterations ({max_iterations})")
@staticmethod
def _is_deferred_answer(text: str) -> bool:
"""Reject drafts that ask permission or defer the requested work."""
lowered = text.lower()
deferral_phrases = (
"would you like me",
"do you want me",
"should i ",
"shall i ",
"if you'd like",
"if you would like",
"let me know if",
"i need to perform",
"i need more information",
"i can refine",
"i can search",
"i can provide",
"need a more targeted search",
)
return "?" in text or any(phrase in lowered for phrase in deferral_phrases)
def _system_prompt(self) -> str: def _system_prompt(self) -> str:
today = datetime.now(timezone.utc).strftime("%B %d, %Y")
return ( return (
"You are Hope's research assistant working on a background task. " "You are Hope's autonomous research assistant working on a background task. "
"Use your tools to gather information thoroughly. When you have " f"Today is {today}. Use your tools until you have directly completed the request. "
"enough, provide a final answer in 1-3 sentences suitable for " "Never ask the user a follow-up question, ask permission to search again, or offer "
"speaking aloud. Plain text only, no markdown or formatting." "to finish later; make reasonable assumptions and continue autonomously. If a web "
"search is broad or inconclusive, immediately run a more targeted query and scrape "
"the most promising result pages, including today's date and specific reputable "
"sources when freshness matters. For headline "
"requests, return concrete named events rather than links to news homepages, and "
"respect any requested item count exactly. Only "
"produce a final answer once it fulfills the original request. The final answer must "
"be 1-3 concise sentences suitable for speaking aloud, with plain text only and no "
"markdown or formatting."
) )
def _load_tools(self) -> list[dict]: def _load_tools(self) -> list[dict]:
@@ -157,7 +211,7 @@ class TaskWorker:
"type": "function", "type": "function",
"function": { "function": {
"name": "web_search", "name": "web_search",
"description": "Search the web for current information using Firecrawl.", "description": "Search the web for current information. Returns titles, descriptions, and URLs.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {"query": {"type": "string"}}, "properties": {"query": {"type": "string"}},
@@ -165,6 +219,18 @@ class TaskWorker:
}, },
}, },
}, },
{
"type": "function",
"function": {
"name": "web_scrape",
"description": "Read the full content of a promising URL returned by web_search.",
"parameters": {
"type": "object",
"properties": {"url": {"type": "string"}},
"required": ["url"],
},
},
},
] ]
async def _execute_tool(self, name: str, args: dict) -> str: async def _execute_tool(self, name: str, args: dict) -> str:
@@ -227,18 +293,46 @@ class TaskWorker:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post( resp = await client.post(
f"{firecrawl_base}/v1/search", f"{firecrawl_base}/v1/search",
json={"query": query, "limit": 3}, json={"query": query, "limit": 5},
) )
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
items = data.get("data", []) items = data.get("data", [])
lines = [ lines = []
f"{r.get('title', '')}: {r.get('description', '')}" for item in items[:5]:
for r in items[:3] title = (item.get("title") or "Untitled").strip()
] description = (item.get("description") or "").strip()
url = (item.get("url") or "").strip()
lines.append(f"{title}: {description}")
if url:
lines.append(f"URL: {url}")
return "\n".join(lines) or "No results found." return "\n".join(lines) or "No results found."
return f"Search failed (HTTP {resp.status_code})." return f"Search failed (HTTP {resp.status_code})."
elif name == "web_scrape":
url = args.get("url", "")
if not url:
return "A URL is required."
firecrawl_base = os.environ.get("FIRECRAWL_BASE", "")
if not firecrawl_base:
return "Web scraping is not configured."
async with httpx.AsyncClient(timeout=90) as client:
resp = await client.post(
f"{firecrawl_base}/v1/scrape",
json={"url": url, "formats": ["markdown"], "timeout": 60000},
)
if resp.status_code != 200:
return f"Scrape failed (HTTP {resp.status_code})."
data = resp.json()
if not data.get("success"):
return f"Scrape failed: {data.get('error', 'unknown error')}"
result = data.get("data", {})
markdown = (result.get("markdown") or "").strip()
if not markdown:
return "No page content was extracted."
title = (result.get("metadata", {}).get("title") or "").strip()
return f"Page: {title}\n\n{markdown[:8000]}"
else: else:
return f"Unknown tool: {name}" return f"Unknown tool: {name}"
+11
View File
@@ -12,6 +12,7 @@ import pytest
sys.path.insert(0, str(Path(__file__).parents[1] / "agent")) sys.path.insert(0, str(Path(__file__).parents[1] / "agent"))
from task_registry import TaskRegistry # noqa: E402 from task_registry import TaskRegistry # noqa: E402
from task_router import should_force_background_task # noqa: E402 from task_router import should_force_background_task # noqa: E402
from task_worker import TaskWorker # noqa: E402
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -46,6 +47,16 @@ def test_direct_requests_do_not_force_dispatch(utterance):
assert not should_force_background_task(utterance) assert not should_force_background_task(utterance)
def test_deferred_task_answers_are_rejected():
assert TaskWorker._is_deferred_answer(
"I need to perform a more targeted search. Would you like me to continue?"
)
assert TaskWorker._is_deferred_answer("I can refine this if you'd like.")
assert not TaskWorker._is_deferred_answer(
"Reuters reports that the agreement was signed today, while the BBC says voting begins Monday."
)
def test_task_event_directories_are_isolated(tmp_path): def test_task_event_directories_are_isolated(tmp_path):
first = TaskRegistry(tmp_path / "room-one") first = TaskRegistry(tmp_path / "room-one")
second = TaskRegistry(tmp_path / "room-two") second = TaskRegistry(tmp_path / "room-two")
+5
View File
@@ -12,12 +12,17 @@ def test_index_serves(client):
assert "Hope" in body assert "Hope" in body
assert "livekit-client.umd.js" in body assert "livekit-client.umd.js" in body
assert "app.js" in body assert "app.js" in body
assert 'id="voiceModeBtn"' in body
assert 'id="textModeBtn"' in body
assert 'id="chatComposer"' in body
def test_app_js_serves(client): def test_app_js_serves(client):
resp = client.get("/app.js") resp = client.get("/app.js")
assert resp.status_code == 200 assert resp.status_code == 200
assert "LiveKitClient" in resp.text or "Room" in resp.text assert "LiveKitClient" in resp.text or "Room" in resp.text
assert 'topic: "lk.chat"' in resp.text
assert "sendModeMessage" in resp.text
def test_style_css_serves(client): def test_style_css_serves(client):
+197 -45
View File
@@ -26,6 +26,7 @@ const VOICES = [
const HISTORY_KEY = "voice_history"; const HISTORY_KEY = "voice_history";
const HISTORY_MAX = 50; const HISTORY_MAX = 50;
const MODE_KEY = "hope_conversation_mode";
// ── State ─────────────────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────────────────
let room = null; let room = null;
@@ -35,6 +36,8 @@ let micLevel = 0; // 0..1, smoothed
let agentAudioLevel = 0; // 0..1, smoothed let agentAudioLevel = 0; // 0..1, smoothed
let userSpeaking = false; // local VAD-ish flag from mic level let userSpeaking = false; // local VAD-ish flag from mic level
let wakeLock = null; let wakeLock = null;
let conversationMode = localStorage.getItem(MODE_KEY) === "text" ? "text" : "voice";
let connectionPromise = null;
function escapeHtml(s) { function escapeHtml(s) {
const d = document.createElement("div"); const d = document.createElement("div");
@@ -65,6 +68,13 @@ const tasksPanel = document.getElementById("tasksPanel");
const tasksList = document.getElementById("tasksList"); const tasksList = document.getElementById("tasksList");
const tasksBadge = document.getElementById("tasksBadge"); const tasksBadge = document.getElementById("tasksBadge");
const closeTasksBtn = document.getElementById("closeTasksBtn"); const closeTasksBtn = document.getElementById("closeTasksBtn");
const voiceModeBtn = document.getElementById("voiceModeBtn");
const textModeBtn = document.getElementById("textModeBtn");
const modeDescriptionEl = document.getElementById("modeDescription");
const transcriptModeLabelEl = document.getElementById("transcriptModeLabel");
const chatComposer = document.getElementById("chatComposer");
const chatInput = document.getElementById("chatInput");
const sendChatBtn = document.getElementById("sendChatBtn");
// ── Background tasks state ────────────────────────────────────────────────── // ── Background tasks state ──────────────────────────────────────────────────
const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error} const tasks = new Map(); // task_id -> {id, description, status, steps: [], result, error}
@@ -133,6 +143,57 @@ tasksBtn.setAttribute("aria-expanded", "false");
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open"))); tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
closeTasksBtn.addEventListener("click", () => setTasksOpen(false)); closeTasksBtn.addEventListener("click", () => setTasksOpen(false));
// ── Voice / text mode ──────────────────────────────────────────────────────
function updateModeUI() {
const isText = conversationMode === "text";
document.body.dataset.mode = conversationMode;
voiceModeBtn.classList.toggle("active", !isText);
textModeBtn.classList.toggle("active", isText);
voiceModeBtn.setAttribute("aria-pressed", String(!isText));
textModeBtn.setAttribute("aria-pressed", String(isText));
modeDescriptionEl.textContent = isText
? "Type when quiet is better. Switch back to voice without losing the thread."
: "Speak naturally. I’ll listen, remember, and answer in real time.";
transcriptModeLabelEl.textContent = isText ? "Shared voice and text history" : "Live voice transcript";
startBtn.innerHTML = isText
? '<span class="button-spark" aria-hidden="true"></span>Connect to Hope'
: '<span class="button-spark" aria-hidden="true"></span>Start conversation';
if (isText) {
setState(room ? "listening" : "idle");
window.setTimeout(() => chatInput.focus(), 0);
}
}
async function setConversationMode(mode) {
if (mode !== "voice" && mode !== "text") return;
conversationMode = mode;
localStorage.setItem(MODE_KEY, mode);
updateModeUI();
if (!room || !room.localParticipant) return;
sendModeMessage(mode);
if (mode === "text") {
await room.localParticipant.setMicrophoneEnabled(false);
micMuted = true;
muteBtn.disabled = true;
updateMuteIcon();
stopVisualLoop();
statusEl.textContent = "Secure room · Text mode";
} else {
await room.startAudio();
await room.localParticipant.setMicrophoneEnabled(true);
micMuted = false;
muteBtn.disabled = false;
updateMuteIcon();
setupMicAnalyser();
startVisualLoop();
statusEl.textContent = "Secure room · Microphone on";
}
}
voiceModeBtn.addEventListener("click", () => setConversationMode("voice"));
textModeBtn.addEventListener("click", () => setConversationMode("text"));
// ── Audio analysers (mic + remote agent audio) ───────────────────────────── // ── Audio analysers (mic + remote agent audio) ─────────────────────────────
let micCtx = null; let micCtx = null;
let micAnalyser = null; let micAnalyser = null;
@@ -202,18 +263,26 @@ function readAgentAudioLevel() {
// ── State machine ─────────────────────────────────────────────────────────── // ── State machine ───────────────────────────────────────────────────────────
const STATE_LABELS = { const STATE_LABELS = {
idle: "Ready when you are", voice: {
connecting: "Opening a secure room", idle: "Ready when you are",
listening: "Listening", connecting: "Opening a secure room",
thinking: "Thinking it through", listening: "Listening",
speaking: "Hope is speaking", thinking: "Thinking it through",
speaking: "Hope is speaking",
},
text: {
idle: "Ready to type",
connecting: "Opening a secure room",
listening: "Text chat ready",
thinking: "Hope is writing",
speaking: "Hope replied",
},
}; };
function setState(next) { function setState(next) {
if (next === uiState) return;
uiState = next; uiState = next;
orbEl.dataset.state = next; orbEl.dataset.state = next;
stateLabelEl.textContent = STATE_LABELS[next] || next; stateLabelEl.textContent = STATE_LABELS[conversationMode][next] || next;
// Thinking timer: show the "thinking..." chip if it lingers past 2s // Thinking timer: show the "thinking..." chip if it lingers past 2s
if (next === "thinking") { if (next === "thinking") {
@@ -235,6 +304,8 @@ function setState(next) {
} }
} }
updateModeUI();
function inferState() { function inferState() {
if (!room || !room.localParticipant) return "idle"; if (!room || !room.localParticipant) return "idle";
if (!agentParticipant) return "connecting"; if (!agentParticipant) return "connecting";
@@ -441,6 +512,10 @@ function handleDataPacket(payload, participant, topic) {
const msg = JSON.parse(new TextDecoder().decode(payload)); const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === "transcript") { if (msg.type === "transcript") {
addMessage(msg.role || "agent", msg.text); addMessage(msg.role || "agent", msg.text);
if (conversationMode === "text" && (msg.role || "agent") === "agent") {
setState("listening");
chatInput.focus();
}
} else if (msg.type === "task_event") { } else if (msg.type === "task_event") {
handleTaskEvent(msg); handleTaskEvent(msg);
} }
@@ -558,9 +633,12 @@ function findAgent(rm) {
function markAgentFound(rm, participant) { function markAgentFound(rm, participant) {
agentParticipant = participant; agentParticipant = participant;
statusEl.textContent = "Hope joined · Preparing microphone…"; statusEl.textContent = conversationMode === "text"
// Send current voice selection to the agent ? "Hope joined · Text chat ready"
: "Hope joined · Preparing microphone…";
// Synchronize voice and interaction mode when the agent joins.
sendVoiceMessage(currentVoiceId(), rm); sendVoiceMessage(currentVoiceId(), rm);
sendModeMessage(conversationMode, rm);
} }
function createRoom(token) { function createRoom(token) {
@@ -665,52 +743,116 @@ function waitForAgent(timeoutMs) {
}); });
} }
startBtn.addEventListener("click", async () => { async function connectConversation() {
try { if (room && agentParticipant) return room;
setState("connecting"); if (connectionPromise) return connectionPromise;
statusEl.textContent = "Opening your secure room…";
await acquireWakeLock();
// Get a signed access token from the token endpoint connectionPromise = (async () => {
const token = await fetchToken(ROOM_NAME); try {
setState("connecting");
statusEl.textContent = "Opening your secure room…";
await acquireWakeLock();
room = await createRoom(token); // Get a signed access token from the token endpoint.
await room.startAudio(); const token = await fetchToken(ROOM_NAME);
room = await createRoom(token);
// The agent is dispatched when we join. If it misses the dispatch // The agent is dispatched when we join. If it misses the dispatch
// (e.g. the server just started and the worker wasn't ready yet), // (for example during a server restart), reconnect once.
// reconnect once to trigger a fresh dispatch. statusEl.textContent = "Inviting Hope into the room…";
statusEl.textContent = "Inviting Hope into the room…"; if (!(await waitForAgent(10000))) {
if (!(await waitForAgent(10000))) { statusEl.textContent = "Hope is taking a moment · Retrying…";
statusEl.textContent = "Hope is taking a moment · Retrying…"; const stale = room;
const stale = room; room = null;
room = null; // detach first so its Disconnected handler agentParticipant = null;
agentParticipant = null; // cannot clobber the UI await stale.disconnect();
await stale.disconnect(); const freshToken = await fetchToken(ROOM_NAME);
const freshToken = await fetchToken(ROOM_NAME); room = await createRoom(freshToken);
room = await createRoom(freshToken); if (!(await waitForAgent(15000))) {
if (!(await waitForAgent(15000))) { throw new Error("Assistant did not join — reload the page and try again");
throw new Error("Assistant did not join — reload the page and try again"); }
} }
sendModeMessage(conversationMode);
if (conversationMode === "voice") {
await room.startAudio();
statusEl.textContent = "Waiting for microphone permission…";
await room.localParticipant.setMicrophoneEnabled(true);
micMuted = false;
setupMicAnalyser();
startVisualLoop();
statusEl.textContent = "Secure room · Microphone on";
} else {
await room.localParticipant.setMicrophoneEnabled(false);
micMuted = true;
stopVisualLoop();
statusEl.textContent = "Secure room · Text mode";
}
startBtn.disabled = true;
stopBtn.disabled = false;
muteBtn.disabled = conversationMode !== "voice";
updateMuteIcon();
setState("listening");
return room;
} catch (err) {
console.error("Connection failed:", err);
statusEl.textContent = `Error: ${err.message}`;
setState("idle");
releaseWakeLock();
throw err;
} finally {
connectionPromise = null;
} }
})();
statusEl.textContent = "Waiting for microphone permission…"; return connectionPromise;
await room.localParticipant.setMicrophoneEnabled(true); }
statusEl.textContent = "Secure room · Microphone on"; startBtn.addEventListener("click", () => {
startBtn.disabled = true; connectConversation().catch(() => {});
stopBtn.disabled = false; });
muteBtn.disabled = false;
micMuted = false;
setupMicAnalyser(); // ── Text chat ──────────────────────────────────────────────────────────────
startVisualLoop(); function resizeChatInput() {
chatInput.style.height = "auto";
chatInput.style.height = `${Math.min(chatInput.scrollHeight, 144)}px`;
}
chatInput.addEventListener("input", () => {
resizeChatInput();
sendChatBtn.disabled = !chatInput.value.trim();
});
chatInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
chatComposer.requestSubmit();
}
});
chatComposer.addEventListener("submit", async (event) => {
event.preventDefault();
const text = chatInput.value.trim();
if (!text) return;
sendChatBtn.disabled = true;
chatInput.disabled = true;
try {
if (conversationMode !== "text") await setConversationMode("text");
await connectConversation();
setState("thinking");
await room.localParticipant.sendText(text, { topic: "lk.chat" });
chatInput.value = "";
resizeChatInput();
} catch (err) { } catch (err) {
console.error("Connection failed:", err); console.error("Text message failed:", err);
statusEl.textContent = `Error: ${err.message}`; statusEl.textContent = `Message not sent: ${err.message}`;
setState("idle"); setState("listening");
releaseWakeLock(); } finally {
chatInput.disabled = false;
sendChatBtn.disabled = !chatInput.value.trim();
chatInput.focus();
} }
}); });
@@ -770,3 +912,13 @@ function sendVoiceMessage(voice, targetRoom) {
reliable: true, reliable: true,
}); });
} }
function sendModeMessage(mode, targetRoom) {
const r = targetRoom || room;
if (!r || !r.localParticipant) return;
const payload = new TextEncoder().encode(JSON.stringify({ type: "set_mode", mode }));
r.localParticipant.publishData(payload, {
topic: "conversation-control",
reliable: true,
});
}
+22 -4
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="favicon.svg"> <link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body data-mode="voice">
<a class="skip-link" href="#conversation">Skip to conversation</a> <a class="skip-link" href="#conversation">Skip to conversation</a>
<main class="app-shell"> <main class="app-shell">
@@ -18,11 +18,21 @@
<span class="brand-glyph" aria-hidden="true"><i></i></span> <span class="brand-glyph" aria-hidden="true"><i></i></span>
<div> <div>
<h1>Hope</h1> <h1>Hope</h1>
<p>Voice companion</p> <p>Voice and text companion</p>
</div> </div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="mode-switch" role="group" aria-label="Conversation mode">
<button id="voiceModeBtn" class="mode-option active" type="button" aria-pressed="true">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path></svg>
<span>Voice</span>
</button>
<button id="textModeBtn" class="mode-option" type="button" aria-pressed="false">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"></path></svg>
<span>Text</span>
</button>
</div>
<div class="identity-chip" aria-label="Current profile: Shane"> <div class="identity-chip" aria-label="Current profile: Shane">
<span class="presence-dot" aria-hidden="true"></span> <span class="presence-dot" aria-hidden="true"></span>
<span>Private to <strong>Shane</strong></span> <span>Private to <strong>Shane</strong></span>
@@ -39,7 +49,7 @@
<section class="voice-console" aria-labelledby="voiceHeading"> <section class="voice-console" aria-labelledby="voiceHeading">
<div class="console-intro"> <div class="console-intro">
<h2 id="voiceHeading">I'm here.</h2> <h2 id="voiceHeading">I'm here.</h2>
<p>Speak naturally. I’ll listen, remember, and answer in real time.</p> <p id="modeDescription">Speak naturally. I’ll listen, remember, and answer in real time.</p>
</div> </div>
<div class="orb-stage"> <div class="orb-stage">
@@ -85,11 +95,19 @@
<div class="transcript-header"> <div class="transcript-header">
<div> <div>
<h2 id="conversationHeading">Conversation</h2> <h2 id="conversationHeading">Conversation</h2>
<p>Live transcript</p> <p id="transcriptModeLabel">Live voice transcript</p>
</div> </div>
<button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button> <button id="clearBtn" class="btn btn-ghost" title="Clear conversation">Clear</button>
</div> </div>
<div id="messages" aria-live="polite"></div> <div id="messages" aria-live="polite"></div>
<form class="chat-composer" id="chatComposer" aria-label="Send a text message">
<label class="sr-only" for="chatInput">Message Hope</label>
<textarea id="chatInput" rows="1" maxlength="2000" placeholder="Message Hope…" autocomplete="off"></textarea>
<button id="sendChatBtn" class="send-chat" type="submit" disabled aria-label="Send message">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z"></path><path d="M22 2 11 13"></path></svg>
</button>
<span class="composer-hint">Enter to send · Shift+Enter for a new line</span>
</form>
</section> </section>
</div> </div>
</main> </main>
+188 -2
View File
@@ -86,6 +86,18 @@ button {
transform: translateY(0); transform: translateY(0);
} }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.app-shell { .app-shell {
width: min(1180px, 100%); width: min(1180px, 100%);
margin: 0 auto; margin: 0 auto;
@@ -171,6 +183,50 @@ button {
gap: 0.65rem; gap: 0.65rem;
} }
.mode-switch {
height: 38px;
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(5, 11, 20, 0.55);
}
.mode-option {
height: 30px;
display: inline-flex;
align-items: center;
gap: 0.38rem;
padding: 0 0.65rem;
border: 0;
border-radius: 7px;
cursor: pointer;
color: var(--ink-muted);
background: transparent;
font-size: 0.72rem;
font-weight: 650;
transition: color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
.mode-option:hover {
color: var(--ink-soft);
}
.mode-option.active {
color: var(--ink);
background: var(--surface-soft);
box-shadow: 0 1px 5px rgba(2, 7, 14, 0.3);
}
.mode-option:focus-visible,
.chat-composer textarea:focus-visible,
.send-chat:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.identity-chip { .identity-chip {
min-height: 38px; min-height: 38px;
gap: 0.55rem; gap: 0.55rem;
@@ -804,6 +860,116 @@ button {
transition: height 70ms linear; transition: height 70ms linear;
} }
/* ── Text composer ──────────────────────────────────────────────────────── */
.chat-composer {
position: relative;
display: none;
align-items: flex-end;
gap: 0.55rem;
padding: 0.75rem 0 1.25rem;
border-top: 1px solid var(--line);
}
body[data-mode="text"] .chat-composer {
display: flex;
}
.chat-composer textarea {
width: 100%;
min-height: 48px;
max-height: 144px;
resize: none;
padding: 0.8rem 3.2rem 0.8rem 0.9rem;
border: 1px solid var(--line-strong);
border-radius: 12px;
color: var(--ink);
background: var(--surface-raised);
font: inherit;
font-size: 0.9rem;
line-height: 1.4;
caret-color: var(--iris);
transition: border-color 160ms ease, background 160ms ease;
}
.chat-composer textarea::placeholder {
color: var(--ink-muted);
}
.chat-composer textarea:focus {
border-color: rgba(139, 167, 255, 0.56);
background: var(--surface-soft);
}
.send-chat {
position: absolute;
right: 0.55rem;
bottom: 2.05rem;
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 0;
border-radius: 9px;
cursor: pointer;
color: #08111f;
background: var(--iris);
transition: opacity 150ms ease, transform 150ms var(--ease-out), background 150ms ease;
}
.send-chat:hover:not(:disabled) {
background: #a9bbff;
transform: translateY(-1px);
}
.send-chat:disabled {
cursor: default;
opacity: 0.25;
}
.composer-hint {
position: absolute;
left: 0.2rem;
bottom: 0.25rem;
color: var(--ink-muted);
font-size: 0.62rem;
}
/* Text mode makes the transcript the primary surface while preserving Hope's
live presence and the same underlying conversation. */
body[data-mode="text"] .workspace {
grid-template-columns: minmax(240px, 0.62fr) minmax(480px, 1.38fr);
}
body[data-mode="text"] .voice-console {
padding: 2rem 1.5rem 1.4rem;
}
body[data-mode="text"] .console-intro h2 {
font-size: 1.55rem;
}
body[data-mode="text"] .orb-stage {
min-height: 220px;
}
body[data-mode="text"] .orb {
width: 180px;
height: 180px;
transform: scale(0.72);
}
body[data-mode="text"] .controls {
max-width: 220px;
}
body[data-mode="text"] #muteBtn {
display: none;
}
body[data-mode="text"] .mic-meter {
visibility: hidden;
}
/* ── Voice settings ─────────────────────────────────────────────────────── */ /* ── Voice settings ─────────────────────────────────────────────────────── */
.sheet-backdrop { .sheet-backdrop {
position: fixed; position: fixed;
@@ -1065,7 +1231,8 @@ button {
calc(0.85rem + env(safe-area-inset-left)); calc(0.85rem + env(safe-area-inset-left));
} }
.workspace { .workspace,
body[data-mode="text"] .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;
height: auto; height: auto;
min-height: auto; min-height: auto;
@@ -1096,10 +1263,15 @@ button {
} }
.brand p, .brand p,
.identity-chip { .identity-chip,
.mode-option svg {
display: none; display: none;
} }
.mode-option {
padding: 0 0.55rem;
}
.workspace { .workspace {
border-right: 0; border-right: 0;
border-left: 0; border-left: 0;
@@ -1111,6 +1283,20 @@ button {
padding: 2.1rem 1.15rem 1.25rem; padding: 2.1rem 1.15rem 1.25rem;
} }
body[data-mode="text"] .voice-console {
min-height: 270px;
padding: 1.35rem 1rem 1rem;
}
body[data-mode="text"] .orb-stage {
min-height: 120px;
margin: -0.75rem 0;
}
body[data-mode="text"] .orb {
transform: scale(0.48);
}
.console-intro h2 { .console-intro h2 {
font-size: 1.7rem; font-size: 1.7rem;
} }