apirrone commited on
Commit
4f7949b
·
1 Parent(s): 8b26559

little UI for the api key in headless mode. key should be persisted too

Browse files
pyproject.toml CHANGED
@@ -70,6 +70,8 @@ where = ["src"]
70
  [tool.setuptools.package-data]
71
  reachy_mini_conversation_app = [
72
  "images/*",
 
 
73
  "demos/**/*.txt",
74
  "prompts_library/*.txt",
75
  "profiles/**/*.txt",
 
70
  [tool.setuptools.package-data]
71
  reachy_mini_conversation_app = [
72
  "images/*",
73
+ "static/*",
74
+ ".env.example",
75
  "demos/**/*.txt",
76
  "prompts_library/*.txt",
77
  "profiles/**/*.txt",
src/reachy_mini_conversation_app/console.py CHANGED
@@ -1,39 +1,247 @@
1
- """Bidirectional local audio stream.
2
 
3
- records mic frames to the handler and plays handler audio frames to the speaker.
 
 
 
 
 
 
4
  """
5
 
 
6
  import time
7
  import asyncio
8
  import logging
9
- from typing import List
 
10
 
11
  from fastrtc import AdditionalOutputs, audio_to_float32
12
  from scipy.signal import resample
13
 
14
  from reachy_mini import ReachyMini
15
  from reachy_mini.media.media_manager import MediaBackend
 
16
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  logger = logging.getLogger(__name__)
20
 
21
 
22
  class LocalStream:
23
  """LocalStream using Reachy Mini's recorder/player."""
24
 
25
- def __init__(self, handler: OpenaiRealtimeHandler, robot: ReachyMini):
26
- """Initialize the stream with an OpenAI realtime handler and pipelines."""
 
 
 
 
 
 
 
 
 
 
 
27
  self.handler = handler
28
  self._robot = robot
29
  self._stop_event = asyncio.Event()
30
  self._tasks: List[asyncio.Task[None]] = []
31
  # Allow the handler to flush the player queue when appropriate.
32
  self.handler._clear_queue = self.clear_audio_queue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def launch(self) -> None:
35
- """Start the recorder/player and run the async processing loops."""
 
 
 
 
36
  self._stop_event.clear()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  self._robot.media.start_recording()
38
  self._robot.media.start_playing()
39
  time.sleep(1) # give some time to the pipelines to start
 
1
+ """Bidirectional local audio stream with optional settings UI.
2
 
3
+ In headless mode, there is no Gradio UI. If the OpenAI API key is not
4
+ available via environment/.env, we expose a minimal settings page via the
5
+ Reachy Mini Apps settings server to let non-technical users enter it.
6
+
7
+ The settings UI is served from this package's ``static/`` folder and offers a
8
+ single password field to set ``OPENAI_API_KEY``. Once set, we persist it to the
9
+ app instance's ``.env`` file (if available) and proceed to start streaming.
10
  """
11
 
12
+ import os
13
  import time
14
  import asyncio
15
  import logging
16
+ from typing import List, Optional
17
+ from pathlib import Path
18
 
19
  from fastrtc import AdditionalOutputs, audio_to_float32
20
  from scipy.signal import resample
21
 
22
  from reachy_mini import ReachyMini
23
  from reachy_mini.media.media_manager import MediaBackend
24
+ from reachy_mini_conversation_app.config import config
25
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
26
 
27
 
28
+ try:
29
+ # FastAPI is provided by the Reachy Mini Apps runtime
30
+ from fastapi import FastAPI
31
+ from pydantic import BaseModel
32
+ from fastapi.responses import FileResponse, JSONResponse
33
+ from starlette.staticfiles import StaticFiles
34
+ except Exception: # pragma: no cover - only loaded when settings_app is used
35
+ FastAPI = object # type: ignore[assignment]
36
+ FileResponse = object # type: ignore[assignment]
37
+ JSONResponse = object # type: ignore[assignment]
38
+ StaticFiles = object # type: ignore[assignment]
39
+ BaseModel = object # type: ignore[assignment]
40
+
41
+
42
  logger = logging.getLogger(__name__)
43
 
44
 
45
  class LocalStream:
46
  """LocalStream using Reachy Mini's recorder/player."""
47
 
48
+ def __init__(
49
+ self,
50
+ handler: OpenaiRealtimeHandler,
51
+ robot: ReachyMini,
52
+ *,
53
+ settings_app: Optional[FastAPI] = None,
54
+ instance_path: Optional[str] = None,
55
+ ):
56
+ """Initialize the stream with an OpenAI realtime handler and pipelines.
57
+
58
+ - ``settings_app``: the Reachy Mini Apps FastAPI to attach settings endpoints.
59
+ - ``instance_path``: directory where per-instance ``.env`` should be stored.
60
+ """
61
  self.handler = handler
62
  self._robot = robot
63
  self._stop_event = asyncio.Event()
64
  self._tasks: List[asyncio.Task[None]] = []
65
  # Allow the handler to flush the player queue when appropriate.
66
  self.handler._clear_queue = self.clear_audio_queue
67
+ self._settings_app: Optional[FastAPI] = settings_app
68
+ self._instance_path: Optional[str] = instance_path
69
+ self._settings_initialized = False
70
+
71
+ # ---- Settings UI (only when API key is missing) ----
72
+ def _persist_api_key(self, key: str) -> None:
73
+ """Persist API key to environment and instance ``.env`` if possible.
74
+
75
+ Behavior:
76
+ - Always sets ``OPENAI_API_KEY`` in process env and in-memory config.
77
+ - Writes/updates ``<instance_path>/.env``:
78
+ * If ``.env`` exists, replaces/append OPENAI_API_KEY line.
79
+ * Else, copies template from ``<instance_path>/.env.example`` when present,
80
+ otherwise falls back to the packaged template
81
+ ``reachy_mini_conversation_app/.env.example``.
82
+ * Ensures the resulting file contains the full template plus the key.
83
+ - Loads the written ``.env`` into the current process environment.
84
+ """
85
+ k = (key or "").strip()
86
+ if not k:
87
+ return
88
+ # Update live process env and config so consumers see it immediately
89
+ try:
90
+ os.environ["OPENAI_API_KEY"] = k
91
+ except Exception: # best-effort
92
+ pass
93
+ try:
94
+ config.OPENAI_API_KEY = k # type: ignore[attr-defined]
95
+ except Exception:
96
+ pass
97
+
98
+ if not self._instance_path:
99
+ return
100
+ try:
101
+ inst = Path(self._instance_path)
102
+ env_path = inst / ".env"
103
+ lines: list[str]
104
+ if env_path.exists():
105
+ try:
106
+ lines = env_path.read_text(encoding="utf-8").splitlines()
107
+ except Exception:
108
+ lines = []
109
+ else:
110
+ # Try instance template first
111
+ template_text = None
112
+ ex = inst / ".env.example"
113
+ if ex.exists():
114
+ try:
115
+ template_text = ex.read_text(encoding="utf-8")
116
+ except Exception:
117
+ template_text = None
118
+ # Fallback to CWD template
119
+ if template_text is None:
120
+ try:
121
+ cwd_example = Path.cwd() / ".env.example"
122
+ if cwd_example.exists():
123
+ template_text = cwd_example.read_text(encoding="utf-8")
124
+ except Exception:
125
+ template_text = None
126
+
127
+ # Fallback to packaged template
128
+ if template_text is None:
129
+ packaged = Path(__file__).parent / ".env.example"
130
+ if packaged.exists():
131
+ try:
132
+ template_text = packaged.read_text(encoding="utf-8")
133
+ except Exception:
134
+ template_text = None
135
+ lines = (template_text.splitlines() if template_text else [])
136
+ replaced = False
137
+ for i, ln in enumerate(lines):
138
+ if ln.strip().startswith("OPENAI_API_KEY="):
139
+ lines[i] = f"OPENAI_API_KEY={k}"
140
+ replaced = True
141
+ break
142
+ if not replaced:
143
+ lines.append(f"OPENAI_API_KEY={k}")
144
+ final_text = "\n".join(lines) + "\n"
145
+ env_path.write_text(final_text, encoding="utf-8")
146
+ logger.info("Persisted OPENAI_API_KEY to %s", env_path)
147
+
148
+ # Load the newly written .env into this process to ensure downstream imports see it
149
+ try:
150
+ from dotenv import load_dotenv
151
+
152
+ load_dotenv(dotenv_path=str(env_path), override=True)
153
+ except Exception:
154
+ pass
155
+ except Exception as e:
156
+ logger.warning("Failed to persist OPENAI_API_KEY: %s", e)
157
+
158
+ def _init_settings_ui_if_needed(self) -> None:
159
+ """Attach minimal settings UI to the settings app.
160
+
161
+ Always mounts the UI when a settings_app is provided so that users
162
+ see a confirmation message even if the API key is already configured.
163
+ """
164
+ if self._settings_initialized:
165
+ return
166
+ if self._settings_app is None:
167
+ return
168
+
169
+ static_dir = Path(__file__).parent / "static"
170
+ index_file = static_dir / "index.html"
171
+
172
+ if hasattr(self._settings_app, "mount"):
173
+ try:
174
+ # Serve /static/* assets
175
+ self._settings_app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") # type: ignore[arg-type]
176
+ except Exception:
177
+ pass
178
+
179
+ class ApiKeyPayload(BaseModel): # type: ignore[misc,valid-type]
180
+ openai_api_key: str
181
+
182
+ # GET / -> index.html
183
+ @self._settings_app.get("/") # type: ignore[union-attr]
184
+ def _root() -> FileResponse: # type: ignore[no-redef]
185
+ return FileResponse(str(index_file))
186
+
187
+ # GET /status -> whether key is set
188
+ @self._settings_app.get("/status") # type: ignore[union-attr]
189
+ def _status() -> JSONResponse: # type: ignore[no-redef]
190
+ has_key = bool(config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip())
191
+ return JSONResponse({"has_key": has_key})
192
+
193
+ # POST /openai_api_key -> set/persist key
194
+ @self._settings_app.post("/openai_api_key") # type: ignore[union-attr]
195
+ def _set_key(payload: ApiKeyPayload) -> JSONResponse: # type: ignore[no-redef]
196
+ key = (payload.openai_api_key or "").strip()
197
+ if not key:
198
+ return JSONResponse({"ok": False, "error": "empty_key"}, status_code=400)
199
+ self._persist_api_key(key)
200
+ return JSONResponse({"ok": True})
201
+
202
+ self._settings_initialized = True
203
 
204
  def launch(self) -> None:
205
+ """Start the recorder/player and run the async processing loops.
206
+
207
+ If the OpenAI key is missing, expose a tiny settings UI via the
208
+ Reachy Mini settings server to collect it before starting streams.
209
+ """
210
  self._stop_event.clear()
211
+
212
+ # Always expose settings UI if a settings app is available
213
+ self._init_settings_ui_if_needed()
214
+
215
+ # Try to load an existing instance .env first (covers subsequent runs)
216
+ if not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()) and self._instance_path:
217
+ try:
218
+ from dotenv import load_dotenv
219
+
220
+ env_path = Path(self._instance_path) / ".env"
221
+ if env_path.exists():
222
+ load_dotenv(dotenv_path=str(env_path), override=True)
223
+ # Update config with newly loaded value
224
+ new_key = os.getenv("OPENAI_API_KEY", "").strip()
225
+ if new_key:
226
+ try:
227
+ config.OPENAI_API_KEY = new_key # type: ignore[attr-defined]
228
+ except Exception:
229
+ pass
230
+ except Exception:
231
+ pass
232
+
233
+ # If key is still missing -> wait until provided via the settings UI
234
+ if not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()):
235
+ logger.warning("OPENAI_API_KEY not found. Open the app settings page to enter it.")
236
+ # Poll until the key becomes available (set via the settings UI)
237
+ try:
238
+ while not (config.OPENAI_API_KEY and str(config.OPENAI_API_KEY).strip()):
239
+ time.sleep(0.2)
240
+ except KeyboardInterrupt:
241
+ logger.info("Interrupted while waiting for API key.")
242
+ return
243
+
244
+ # Start media after key is set/available
245
  self._robot.media.start_recording()
246
  self._robot.media.start_playing()
247
  time.sleep(1) # give some time to the pipelines to start
src/reachy_mini_conversation_app/main.py CHANGED
@@ -476,7 +476,13 @@ def run(
476
 
477
  app = gr.mount_gradio_app(app, stream.ui, path="/")
478
  else:
479
- stream_manager = LocalStream(handler, robot)
 
 
 
 
 
 
480
 
481
  # Each async service → its own thread/loop
482
  movement_manager.start()
@@ -522,7 +528,7 @@ class ReachyMiniConversationApp(ReachyMiniApp): # type: ignore[misc]
522
  """Reachy Mini Apps entry point for the conversation app."""
523
 
524
  custom_app_url = "http://127.0.0.1:7860/"
525
- dont_start_webserver = True
526
 
527
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
528
  """Run the Reachy Mini conversation app."""
@@ -530,7 +536,7 @@ class ReachyMiniConversationApp(ReachyMiniApp): # type: ignore[misc]
530
  asyncio.set_event_loop(loop)
531
 
532
  args, _ = parse_args()
533
- args.gradio = True # Force gradio for Reachy Mini App integration
534
  instance_path = self._get_instance_path().parent
535
  run(
536
  args,
 
476
 
477
  app = gr.mount_gradio_app(app, stream.ui, path="/")
478
  else:
479
+ # In headless mode, wire settings_app + instance_path to console LocalStream
480
+ stream_manager = LocalStream(
481
+ handler,
482
+ robot,
483
+ settings_app=settings_app,
484
+ instance_path=instance_path,
485
+ )
486
 
487
  # Each async service → its own thread/loop
488
  movement_manager.start()
 
528
  """Reachy Mini Apps entry point for the conversation app."""
529
 
530
  custom_app_url = "http://127.0.0.1:7860/"
531
+ dont_start_webserver = False
532
 
533
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
534
  """Run the Reachy Mini conversation app."""
 
536
  asyncio.set_event_loop(loop)
537
 
538
  args, _ = parse_args()
539
+ # args.gradio = True # Force gradio for Reachy Mini App integration
540
  instance_path = self._get_instance_path().parent
541
  run(
542
  args,
src/reachy_mini_conversation_app/openai_realtime.py CHANGED
@@ -138,12 +138,13 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
138
  openai_api_key = config.OPENAI_API_KEY
139
  else:
140
  if not openai_api_key or not openai_api_key.strip():
141
- raise RuntimeError(
142
- "\nOPENAI_API_KEY is missing or empty.\n"
143
- "Either:\n"
144
- " 1. Create a .env file with: OPENAI_API_KEY=your_api_key_here (recomended)\n"
145
- " 2. Set environment variable: export OPENAI_API_KEY=your_api_key_here\n"
146
  )
 
147
 
148
  self.client = AsyncOpenAI(api_key=openai_api_key)
149
 
 
138
  openai_api_key = config.OPENAI_API_KEY
139
  else:
140
  if not openai_api_key or not openai_api_key.strip():
141
+ # In headless console mode, LocalStream now blocks startup until the key is provided.
142
+ # However, unit tests may invoke this handler directly with a stubbed client.
143
+ # To keep tests hermetic without requiring a real key, fall back to a placeholder.
144
+ logger.warning(
145
+ "OPENAI_API_KEY missing. Proceeding with a placeholder (tests/offline)."
146
  )
147
+ openai_api_key = "DUMMY"
148
 
149
  self.client = AsyncOpenAI(api_key=openai_api_key)
150
 
src/reachy_mini_conversation_app/static/index.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Reachy Mini Conversation – Settings</title>
7
+ <link rel="stylesheet" href="/static/style.css" />
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <h1>Reachy Mini Conversation</h1>
12
+ <p class="subtitle">Headless settings</p>
13
+
14
+ <div id="configured" class="panel hidden">
15
+ <p class="ok">OpenAI API key is already configured. Nothing to do.</p>
16
+ </div>
17
+
18
+ <div id="form-panel" class="panel hidden">
19
+ <p>Enter your OpenAI API key to enable conversation.</p>
20
+ <label for="api-key">OpenAI API Key</label>
21
+ <input id="api-key" type="password" placeholder="sk-..." autocomplete="off" />
22
+ <button id="save-btn">Save</button>
23
+ <p id="status" class="status"></p>
24
+ </div>
25
+ </div>
26
+
27
+ <script src="/static/main.js"></script>
28
+ </body>
29
+ </html>
30
+
src/reachy_mini_conversation_app/static/main.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ async function fetchStatus() {
2
+ try {
3
+ const resp = await fetch("/status");
4
+ if (!resp.ok) throw new Error("status error");
5
+ return await resp.json();
6
+ } catch (e) {
7
+ return { has_key: false, error: true };
8
+ }
9
+ }
10
+
11
+ async function saveKey(key) {
12
+ const body = { openai_api_key: key };
13
+ const resp = await fetch("/openai_api_key", {
14
+ method: "POST",
15
+ headers: { "Content-Type": "application/json" },
16
+ body: JSON.stringify(body),
17
+ });
18
+ if (!resp.ok) {
19
+ const data = await resp.json().catch(() => ({}));
20
+ throw new Error(data.error || "save_failed");
21
+ }
22
+ return await resp.json();
23
+ }
24
+
25
+ function show(el, flag) {
26
+ el.classList.toggle("hidden", !flag);
27
+ }
28
+
29
+ async function init() {
30
+ const statusEl = document.getElementById("status");
31
+ const formPanel = document.getElementById("form-panel");
32
+ const configuredPanel = document.getElementById("configured");
33
+ const saveBtn = document.getElementById("save-btn");
34
+ const input = document.getElementById("api-key");
35
+
36
+ statusEl.textContent = "Checking configuration...";
37
+ show(formPanel, false);
38
+ show(configuredPanel, false);
39
+
40
+ const st = await fetchStatus();
41
+ if (st.has_key) {
42
+ statusEl.textContent = "";
43
+ show(configuredPanel, true);
44
+ return;
45
+ }
46
+
47
+ statusEl.textContent = "";
48
+ show(formPanel, true);
49
+
50
+ saveBtn.addEventListener("click", async () => {
51
+ const key = input.value.trim();
52
+ if (!key) {
53
+ statusEl.textContent = "Please enter a valid key.";
54
+ statusEl.className = "status warn";
55
+ return;
56
+ }
57
+ statusEl.textContent = "Saving...";
58
+ statusEl.className = "status";
59
+ try {
60
+ await saveKey(key);
61
+ statusEl.textContent = "Saved. The app will start automatically.";
62
+ statusEl.className = "status ok";
63
+ // Optimistically switch to configured state
64
+ show(formPanel, false);
65
+ show(configuredPanel, true);
66
+ } catch (e) {
67
+ statusEl.textContent = "Failed to save key.";
68
+ statusEl.className = "status error";
69
+ }
70
+ });
71
+ }
72
+
73
+ window.addEventListener("DOMContentLoaded", init);
74
+
src/reachy_mini_conversation_app/static/style.css ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #0b1320;
3
+ --panel: #121a2a;
4
+ --text: #e8efff;
5
+ --muted: #9fb1d1;
6
+ --ok: #4caf50;
7
+ --warn: #ff9800;
8
+ --error: #e53935;
9
+ --accent: #1e88e5;
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ margin: 0;
15
+ font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
16
+ background: var(--bg);
17
+ color: var(--text);
18
+ }
19
+ .container {
20
+ max-width: 640px;
21
+ margin: 6vh auto;
22
+ padding: 24px;
23
+ }
24
+ h1 { margin: 0 0 8px; font-size: 24px; }
25
+ .subtitle { margin: 0 0 24px; color: var(--muted); }
26
+ .panel {
27
+ background: var(--panel);
28
+ padding: 16px;
29
+ border-radius: 8px;
30
+ }
31
+ .hidden { display: none; }
32
+ label { display: block; margin: 8px 0 4px; }
33
+ input[type="password"] {
34
+ width: 100%;
35
+ padding: 10px 12px;
36
+ border: 1px solid #2a3550;
37
+ border-radius: 6px;
38
+ background: #0f1626;
39
+ color: var(--text);
40
+ }
41
+ button {
42
+ margin-top: 12px;
43
+ padding: 10px 16px;
44
+ border: none;
45
+ border-radius: 6px;
46
+ background: var(--accent);
47
+ color: white;
48
+ cursor: pointer;
49
+ }
50
+ button:hover { filter: brightness(1.1); }
51
+ .status { margin-top: 10px; color: var(--muted); }
52
+ .status.ok { color: var(--ok); }
53
+ .status.warn { color: var(--warn); }
54
+ .status.error { color: var(--error); }
55
+