onitsche Claude Opus 4.8 commited on
Commit
0b342d8
·
1 Parent(s): c4614b8

Add web-UI field for the Anthropic API key

Browse files

Conversation mode needs an Anthropic key; previously only the
ANTHROPIC_API_KEY env var worked, which is awkward to set on the wireless
robot. Add an alternative: enter the key in the app's own settings UI.

- llm.py: get_api_key() reads ~/.config/talk/api_key first, then the env
var (so a key entered in the UI always wins); add has_api_key() and
save_api_key()
- main.py: POST /set_api_key endpoint; /status now also reports api_key_set
- static UI: "Einstellungen" section with a password field, save button,
and a live "key set?" indicator
- the key file lives outside the repo (chmod 600) so it is never committed
or packaged into the published Space

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (6) hide show
  1. CLAUDE.md +7 -3
  2. talk/llm.py +48 -2
  3. talk/main.py +21 -2
  4. talk/static/index.html +12 -0
  5. talk/static/main.js +50 -0
  6. talk/static/style.css +57 -0
CLAUDE.md CHANGED
@@ -32,7 +32,11 @@ reachy-mini-app run talk
32
 
33
  The control panel web UI is served at `http://0.0.0.0:8042` while the app runs.
34
 
35
- The `ANTHROPIC_API_KEY` environment variable must be set for conversation mode.
 
 
 
 
36
 
37
  ## Publishing
38
 
@@ -75,7 +79,7 @@ SLEEPING → (speech detected) → TIME → CONVERSING → (silence/antenna pres
75
 
76
  - **`talk/tts.py`**: edge-tts (MS neural, `de-DE-KatjaNeural`) → MP3 → `media.play_sound()`. Falls back to espeak-ng. Blocks for estimated playback duration.
77
  - **`talk/stt.py`**: records from ReSpeaker (16 kHz float32), uses RMS-energy VAD (DoA is for head direction only), converts to mono 16-bit WAV, transcribes via Google Speech Recognition. Accepts an `idle_timeout` so a silent conversation returns to sleep.
78
- - **`talk/llm.py`**: stateless Claude API wrapper. Caller owns `messages` list. Requires `ANTHROPIC_API_KEY`.
79
 
80
  ## Key SDK APIs
81
 
@@ -104,4 +108,4 @@ reachy_mini.goto_sleep()
104
 
105
  ## Settings UI
106
 
107
- `talk/static/` polls `GET /status` every second. Returns `{state, last_user, last_assistant}`. Shows colour-coded status chip and conversation bubbles (user on right, assistant on left) during CONVERSING.
 
32
 
33
  The control panel web UI is served at `http://0.0.0.0:8042` while the app runs.
34
 
35
+ Conversation mode needs an Anthropic API key. Provide it either via the
36
+ `ANTHROPIC_API_KEY` environment variable, or by entering it in the app's web UI
37
+ (`http://0.0.0.0:8042` → *Einstellungen*). A key entered in the UI is stored at
38
+ `~/.config/talk/api_key` (chmod 600, outside the repo) and takes precedence over
39
+ the env var.
40
 
41
  ## Publishing
42
 
 
79
 
80
  - **`talk/tts.py`**: edge-tts (MS neural, `de-DE-KatjaNeural`) → MP3 → `media.play_sound()`. Falls back to espeak-ng. Blocks for estimated playback duration.
81
  - **`talk/stt.py`**: records from ReSpeaker (16 kHz float32), uses RMS-energy VAD (DoA is for head direction only), converts to mono 16-bit WAV, transcribes via Google Speech Recognition. Accepts an `idle_timeout` so a silent conversation returns to sleep.
82
+ - **`talk/llm.py`**: stateless Claude API wrapper. Caller owns `messages` list. Resolves the API key via `get_api_key()` — the web-UI file (`~/.config/talk/api_key`) first, then `ANTHROPIC_API_KEY`. Also exposes `has_api_key()` and `save_api_key()` for the web UI.
83
 
84
  ## Key SDK APIs
85
 
 
108
 
109
  ## Settings UI
110
 
111
+ `talk/static/` polls `GET /status` every second. Returns `{state, last_user, last_assistant, api_key_set}`. Shows colour-coded status chip and conversation bubbles (user on right, assistant on left) during CONVERSING. An *Einstellungen* section lets the user enter the Anthropic API key, which is `POST`ed to `/set_api_key` (`{api_key}`) and persisted via `save_api_key()`; the `api_key_set` flag drives a "key set?" indicator.
talk/llm.py CHANGED
@@ -7,6 +7,7 @@ caller owns the history (keeps this module stateless and testable).
7
 
8
  import logging
9
  import os
 
10
  from typing import Optional
11
 
12
  logger = logging.getLogger(__name__)
@@ -19,6 +20,51 @@ SYSTEM_PROMPT = (
19
  MODEL = "claude-sonnet-4-6"
20
  MAX_TOKENS = 300
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def get_response(messages: list) -> Optional[str]:
24
  """Send messages to Claude and return the assistant reply.
@@ -36,9 +82,9 @@ def get_response(messages: list) -> Optional[str]:
36
  try:
37
  import anthropic
38
 
39
- api_key = os.environ.get("ANTHROPIC_API_KEY")
40
  if not api_key:
41
- logger.error("ANTHROPIC_API_KEY not set")
42
  return None
43
 
44
  client = anthropic.Anthropic(api_key=api_key)
 
7
 
8
  import logging
9
  import os
10
+ from pathlib import Path
11
  from typing import Optional
12
 
13
  logger = logging.getLogger(__name__)
 
20
  MODEL = "claude-sonnet-4-6"
21
  MAX_TOKENS = 300
22
 
23
+ # The API key can come from two places (file wins, so a key entered in the web
24
+ # UI always takes precedence over a stale env var). The file lives OUTSIDE the
25
+ # package directory so it is never committed or packaged into the published
26
+ # Hugging Face Space.
27
+ API_KEY_FILE = Path.home() / ".config" / "talk" / "api_key"
28
+
29
+
30
+ def get_api_key() -> Optional[str]:
31
+ """Return the Anthropic API key — web-UI file first, then env var."""
32
+ try:
33
+ if API_KEY_FILE.is_file():
34
+ key = API_KEY_FILE.read_text(encoding="utf-8").strip()
35
+ if key:
36
+ return key
37
+ except OSError as exc:
38
+ logger.warning("Could not read API key file %s: %s", API_KEY_FILE, exc)
39
+ return os.environ.get("ANTHROPIC_API_KEY")
40
+
41
+
42
+ def has_api_key() -> bool:
43
+ """True if a key is configured (without reading the secret on every call)."""
44
+ try:
45
+ if API_KEY_FILE.is_file() and API_KEY_FILE.stat().st_size > 0:
46
+ return True
47
+ except OSError:
48
+ pass
49
+ return bool(os.environ.get("ANTHROPIC_API_KEY"))
50
+
51
+
52
+ def save_api_key(key: str) -> None:
53
+ """Persist the API key to a user-local file (chmod 600), creating parents.
54
+
55
+ Raises ValueError if the key is empty. Stored outside the package dir so it
56
+ never ends up in git or the published Space.
57
+ """
58
+ key = key.strip()
59
+ if not key:
60
+ raise ValueError("API key must not be empty")
61
+ API_KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
62
+ API_KEY_FILE.write_text(key, encoding="utf-8")
63
+ try:
64
+ API_KEY_FILE.chmod(0o600)
65
+ except OSError:
66
+ pass
67
+
68
 
69
  def get_response(messages: list) -> Optional[str]:
70
  """Send messages to Claude and return the assistant reply.
 
82
  try:
83
  import anthropic
84
 
85
+ api_key = get_api_key()
86
  if not api_key:
87
+ logger.error("No API key set (web UI or ANTHROPIC_API_KEY env)")
88
  return None
89
 
90
  client = anthropic.Anthropic(api_key=api_key)
talk/main.py CHANGED
@@ -21,9 +21,11 @@ from zoneinfo import ZoneInfo
21
  _TZ = ZoneInfo("Europe/Berlin")
22
 
23
  import numpy as np
 
 
24
  from reachy_mini import ReachyMini, ReachyMiniApp
25
 
26
- from talk.llm import get_response
27
  from talk.stt import record_utterance, transcribe
28
  from talk.tts import speak
29
 
@@ -108,6 +110,10 @@ class State(Enum):
108
  CONVERSING = auto()
109
 
110
 
 
 
 
 
111
  class Talk(ReachyMiniApp):
112
  custom_app_url: str | None = "http://0.0.0.0:8042"
113
  request_media_backend: str | None = None
@@ -119,7 +125,20 @@ class Talk(ReachyMiniApp):
119
  @self.settings_app.get("/status")
120
  def get_status():
121
  with _lock:
122
- return dict(_shared)
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  reachy_mini.goto_sleep()
125
  state = State.SLEEPING
 
21
  _TZ = ZoneInfo("Europe/Berlin")
22
 
23
  import numpy as np
24
+ from fastapi import HTTPException
25
+ from pydantic import BaseModel
26
  from reachy_mini import ReachyMini, ReachyMiniApp
27
 
28
+ from talk.llm import get_response, has_api_key, save_api_key
29
  from talk.stt import record_utterance, transcribe
30
  from talk.tts import speak
31
 
 
110
  CONVERSING = auto()
111
 
112
 
113
+ class ApiKeyRequest(BaseModel):
114
+ api_key: str
115
+
116
+
117
  class Talk(ReachyMiniApp):
118
  custom_app_url: str | None = "http://0.0.0.0:8042"
119
  request_media_backend: str | None = None
 
125
  @self.settings_app.get("/status")
126
  def get_status():
127
  with _lock:
128
+ data = dict(_shared)
129
+ data["api_key_set"] = has_api_key()
130
+ return data
131
+
132
+ @self.settings_app.post("/set_api_key")
133
+ def set_api_key(body: ApiKeyRequest):
134
+ try:
135
+ save_api_key(body.api_key)
136
+ except ValueError as exc:
137
+ raise HTTPException(status_code=400, detail=str(exc))
138
+ except OSError as exc:
139
+ raise HTTPException(status_code=500, detail=f"Could not save key: {exc}")
140
+ logger.info("API key updated via web UI")
141
+ return {"ok": True, "api_key_set": True}
142
 
143
  reachy_mini.goto_sleep()
144
  state = State.SLEEPING
talk/static/index.html CHANGED
@@ -13,6 +13,18 @@
13
  <div id="bubble-user" class="bubble user" style="display:none"></div>
14
  <div id="bubble-assistant" class="bubble assistant" style="display:none"></div>
15
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
16
  <script src="/static/main.js"></script>
17
  </body>
18
  </html>
 
13
  <div id="bubble-user" class="bubble user" style="display:none"></div>
14
  <div id="bubble-assistant" class="bubble assistant" style="display:none"></div>
15
  </div>
16
+
17
+ <details id="settings">
18
+ <summary>Einstellungen</summary>
19
+ <label for="api-key">Anthropic API-Key</label>
20
+ <div class="key-row">
21
+ <input type="password" id="api-key" placeholder="sk-ant-…"
22
+ autocomplete="off" spellcheck="false">
23
+ <button id="save-key" type="button">Speichern</button>
24
+ </div>
25
+ <p id="key-status" class="key-status"></p>
26
+ </details>
27
+
28
  <script src="/static/main.js"></script>
29
  </body>
30
  </html>
talk/static/main.js CHANGED
@@ -8,6 +8,54 @@ const STATE_LABELS = {
8
 
9
  const CONVERSATION_STATES = new Set(["listening", "processing", "responding"]);
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  async function poll() {
12
  try {
13
  const r = await fetch("/status");
@@ -40,6 +88,8 @@ async function poll() {
40
  } else {
41
  assistantEl.style.display = "none";
42
  }
 
 
43
  } catch (_) {}
44
  }
45
 
 
8
 
9
  const CONVERSATION_STATES = new Set(["listening", "processing", "responding"]);
10
 
11
+ // --- API key settings ---------------------------------------------------
12
+ const keyInput = document.getElementById("api-key");
13
+ const keyStatus = document.getElementById("key-status");
14
+ const saveBtn = document.getElementById("save-key");
15
+
16
+ function showKeyStatus(text, cls) {
17
+ keyStatus.textContent = text;
18
+ keyStatus.className = "key-status " + (cls || "");
19
+ }
20
+
21
+ function reflectKeySet(isSet) {
22
+ // Don't clobber feedback while the user is editing the field.
23
+ if (document.activeElement === keyInput) return;
24
+ if (isSet) {
25
+ showKeyStatus("✓ API-Key ist gesetzt", "ok");
26
+ } else {
27
+ showKeyStatus("⚠ Kein API-Key gesetzt", "warn");
28
+ }
29
+ }
30
+
31
+ saveBtn.addEventListener("click", async () => {
32
+ const key = keyInput.value.trim();
33
+ if (!key) {
34
+ showKeyStatus("Bitte einen Key eingeben.", "warn");
35
+ return;
36
+ }
37
+ saveBtn.disabled = true;
38
+ try {
39
+ const r = await fetch("/set_api_key", {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ api_key: key }),
43
+ });
44
+ if (r.ok) {
45
+ keyInput.value = "";
46
+ showKeyStatus("✓ API-Key gespeichert", "ok");
47
+ } else {
48
+ const err = await r.json().catch(() => ({}));
49
+ showKeyStatus("Fehler: " + (err.detail || r.status), "warn");
50
+ }
51
+ } catch (_) {
52
+ showKeyStatus("Netzwerkfehler beim Speichern", "warn");
53
+ } finally {
54
+ saveBtn.disabled = false;
55
+ }
56
+ });
57
+
58
+ // --- Status polling -----------------------------------------------------
59
  async function poll() {
60
  try {
61
  const r = await fetch("/status");
 
88
  } else {
89
  assistantEl.style.display = "none";
90
  }
91
+
92
+ reflectKeySet(data.api_key_set);
93
  } catch (_) {}
94
  }
95
 
talk/static/style.css CHANGED
@@ -55,3 +55,60 @@ h1 { margin-bottom: 1rem; }
55
  color: #14532d;
56
  align-self: flex-start;
57
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  color: #14532d;
56
  align-self: flex-start;
57
  }
58
+
59
+ #settings {
60
+ margin-top: 2rem;
61
+ border-top: 1px solid #eee;
62
+ padding-top: 1rem;
63
+ font-size: 0.95rem;
64
+ }
65
+
66
+ #settings summary {
67
+ cursor: pointer;
68
+ font-weight: 600;
69
+ margin-bottom: 0.75rem;
70
+ }
71
+
72
+ #settings label {
73
+ display: block;
74
+ margin-bottom: 0.35rem;
75
+ color: #555;
76
+ }
77
+
78
+ .key-row {
79
+ display: flex;
80
+ gap: 0.5rem;
81
+ }
82
+
83
+ .key-row input {
84
+ flex: 1;
85
+ padding: 0.5rem 0.6rem;
86
+ border: 1px solid #ccc;
87
+ border-radius: 8px;
88
+ font-size: 0.95rem;
89
+ }
90
+
91
+ .key-row button {
92
+ padding: 0.5rem 0.9rem;
93
+ border: none;
94
+ border-radius: 8px;
95
+ background: #4f46e5;
96
+ color: #fff;
97
+ font-size: 0.95rem;
98
+ cursor: pointer;
99
+ }
100
+
101
+ .key-row button:disabled {
102
+ opacity: 0.6;
103
+ cursor: default;
104
+ }
105
+
106
+ .key-status {
107
+ margin-top: 0.5rem;
108
+ margin-bottom: 0;
109
+ font-size: 0.9rem;
110
+ min-height: 1.2em;
111
+ }
112
+
113
+ .key-status.ok { color: #065f46; }
114
+ .key-status.warn { color: #92400e; }