Merge branch '62-appify-the-demo' into 62-spinoff-startup-personalities
Browse files
src/reachy_mini_conversation_app/console.py
CHANGED
|
@@ -275,6 +275,35 @@ class LocalStream:
|
|
| 275 |
self._persist_api_key(key)
|
| 276 |
return JSONResponse({"ok": True})
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
self._settings_initialized = True
|
| 279 |
|
| 280 |
def launch(self) -> None:
|
|
|
|
| 275 |
self._persist_api_key(key)
|
| 276 |
return JSONResponse({"ok": True})
|
| 277 |
|
| 278 |
+
# POST /validate_api_key -> validate key without persisting it
|
| 279 |
+
@self._settings_app.post("/validate_api_key")
|
| 280 |
+
async def _validate_key(payload: ApiKeyPayload) -> JSONResponse:
|
| 281 |
+
key = (payload.openai_api_key or "").strip()
|
| 282 |
+
if not key:
|
| 283 |
+
return JSONResponse({"valid": False, "error": "empty_key"}, status_code=400)
|
| 284 |
+
|
| 285 |
+
# Try to validate by checking if we can fetch the models
|
| 286 |
+
try:
|
| 287 |
+
import httpx
|
| 288 |
+
headers = {
|
| 289 |
+
"Authorization": f"Bearer {key}",
|
| 290 |
+
"Content-Type": "application/json"
|
| 291 |
+
}
|
| 292 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 293 |
+
response = await client.get(
|
| 294 |
+
"https://api.openai.com/v1/models",
|
| 295 |
+
headers=headers
|
| 296 |
+
)
|
| 297 |
+
if response.status_code == 200:
|
| 298 |
+
return JSONResponse({"valid": True})
|
| 299 |
+
elif response.status_code == 401:
|
| 300 |
+
return JSONResponse({"valid": False, "error": "invalid_api_key"}, status_code=401)
|
| 301 |
+
else:
|
| 302 |
+
return JSONResponse({"valid": False, "error": "validation_failed"}, status_code=response.status_code)
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.warning(f"API key validation failed: {e}")
|
| 305 |
+
return JSONResponse({"valid": False, "error": "validation_error"}, status_code=500)
|
| 306 |
+
|
| 307 |
self._settings_initialized = True
|
| 308 |
|
| 309 |
def launch(self) -> None:
|
src/reachy_mini_conversation_app/static/index.html
CHANGED
|
@@ -28,6 +28,7 @@
|
|
| 28 |
<span class="chip chip-ok">Connected</span>
|
| 29 |
</div>
|
| 30 |
<p class="muted">OpenAI API key is already configured. You can jump straight to personalities.</p>
|
|
|
|
| 31 |
</div>
|
| 32 |
|
| 33 |
<div id="form-panel" class="panel hidden">
|
|
|
|
| 28 |
<span class="chip chip-ok">Connected</span>
|
| 29 |
</div>
|
| 30 |
<p class="muted">OpenAI API key is already configured. You can jump straight to personalities.</p>
|
| 31 |
+
<button id="change-key-btn" class="ghost">Change API key</button>
|
| 32 |
</div>
|
| 33 |
|
| 34 |
<div id="form-panel" class="panel hidden">
|
src/reachy_mini_conversation_app/static/main.js
CHANGED
|
@@ -57,6 +57,20 @@ async function waitForPersonalityData(timeoutMs = 15000) {
|
|
| 57 |
}
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
async function saveKey(key) {
|
| 61 |
const body = { openai_api_key: key };
|
| 62 |
const resp = await fetch("/openai_api_key", {
|
|
@@ -173,6 +187,7 @@ async function init() {
|
|
| 173 |
const configuredPanel = document.getElementById("configured");
|
| 174 |
const personalityPanel = document.getElementById("personality-panel");
|
| 175 |
const saveBtn = document.getElementById("save-btn");
|
|
|
|
| 176 |
const input = document.getElementById("api-key");
|
| 177 |
|
| 178 |
// Personality elements
|
|
@@ -205,22 +220,55 @@ async function init() {
|
|
| 205 |
show(configuredPanel, true);
|
| 206 |
}
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
saveBtn.addEventListener("click", async () => {
|
| 209 |
const key = input.value.trim();
|
| 210 |
if (!key) {
|
| 211 |
statusEl.textContent = "Please enter a valid key.";
|
| 212 |
statusEl.className = "status warn";
|
|
|
|
| 213 |
return;
|
| 214 |
}
|
| 215 |
-
statusEl.textContent = "
|
| 216 |
statusEl.className = "status";
|
|
|
|
| 217 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
await saveKey(key);
|
| 219 |
statusEl.textContent = "Saved. Reloading…";
|
| 220 |
statusEl.className = "status ok";
|
| 221 |
window.location.reload();
|
| 222 |
} catch (e) {
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
statusEl.className = "status error";
|
| 225 |
}
|
| 226 |
});
|
|
|
|
| 57 |
}
|
| 58 |
}
|
| 59 |
|
| 60 |
+
async function validateKey(key) {
|
| 61 |
+
const body = { openai_api_key: key };
|
| 62 |
+
const resp = await fetch("/validate_api_key", {
|
| 63 |
+
method: "POST",
|
| 64 |
+
headers: { "Content-Type": "application/json" },
|
| 65 |
+
body: JSON.stringify(body),
|
| 66 |
+
});
|
| 67 |
+
const data = await resp.json().catch(() => ({}));
|
| 68 |
+
if (!resp.ok) {
|
| 69 |
+
throw new Error(data.error || "validation_failed");
|
| 70 |
+
}
|
| 71 |
+
return data;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
async function saveKey(key) {
|
| 75 |
const body = { openai_api_key: key };
|
| 76 |
const resp = await fetch("/openai_api_key", {
|
|
|
|
| 187 |
const configuredPanel = document.getElementById("configured");
|
| 188 |
const personalityPanel = document.getElementById("personality-panel");
|
| 189 |
const saveBtn = document.getElementById("save-btn");
|
| 190 |
+
const changeKeyBtn = document.getElementById("change-key-btn");
|
| 191 |
const input = document.getElementById("api-key");
|
| 192 |
|
| 193 |
// Personality elements
|
|
|
|
| 220 |
show(configuredPanel, true);
|
| 221 |
}
|
| 222 |
|
| 223 |
+
// Handler for "Change API key" button
|
| 224 |
+
changeKeyBtn.addEventListener("click", () => {
|
| 225 |
+
show(configuredPanel, false);
|
| 226 |
+
show(formPanel, true);
|
| 227 |
+
input.value = "";
|
| 228 |
+
statusEl.textContent = "";
|
| 229 |
+
statusEl.className = "status";
|
| 230 |
+
});
|
| 231 |
+
|
| 232 |
+
// Remove error styling when user starts typing
|
| 233 |
+
input.addEventListener("input", () => {
|
| 234 |
+
input.classList.remove("error");
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
saveBtn.addEventListener("click", async () => {
|
| 238 |
const key = input.value.trim();
|
| 239 |
if (!key) {
|
| 240 |
statusEl.textContent = "Please enter a valid key.";
|
| 241 |
statusEl.className = "status warn";
|
| 242 |
+
input.classList.add("error");
|
| 243 |
return;
|
| 244 |
}
|
| 245 |
+
statusEl.textContent = "Validating API key...";
|
| 246 |
statusEl.className = "status";
|
| 247 |
+
input.classList.remove("error");
|
| 248 |
try {
|
| 249 |
+
// First validate the key
|
| 250 |
+
const validation = await validateKey(key);
|
| 251 |
+
if (!validation.valid) {
|
| 252 |
+
statusEl.textContent = "Invalid API key. Please check your key and try again.";
|
| 253 |
+
statusEl.className = "status error";
|
| 254 |
+
input.classList.add("error");
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
// If valid, save it
|
| 259 |
+
statusEl.textContent = "Key valid! Saving...";
|
| 260 |
+
statusEl.className = "status ok";
|
| 261 |
await saveKey(key);
|
| 262 |
statusEl.textContent = "Saved. Reloading…";
|
| 263 |
statusEl.className = "status ok";
|
| 264 |
window.location.reload();
|
| 265 |
} catch (e) {
|
| 266 |
+
input.classList.add("error");
|
| 267 |
+
if (e.message === "invalid_api_key") {
|
| 268 |
+
statusEl.textContent = "Invalid API key. Please check your key and try again.";
|
| 269 |
+
} else {
|
| 270 |
+
statusEl.textContent = "Failed to validate/save key. Please try again.";
|
| 271 |
+
}
|
| 272 |
statusEl.className = "status error";
|
| 273 |
}
|
| 274 |
});
|
src/reachy_mini_conversation_app/static/style.css
CHANGED
|
@@ -163,6 +163,10 @@ textarea:focus {
|
|
| 163 |
outline: none;
|
| 164 |
box-shadow: 0 0 0 3px rgba(94, 240, 193, 0.15);
|
| 165 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
select option {
|
| 167 |
background: #0b152a;
|
| 168 |
color: var(--text);
|
|
|
|
| 163 |
outline: none;
|
| 164 |
box-shadow: 0 0 0 3px rgba(94, 240, 193, 0.15);
|
| 165 |
}
|
| 166 |
+
input.error {
|
| 167 |
+
border-color: var(--error);
|
| 168 |
+
box-shadow: 0 0 0 3px rgba(255, 92, 112, 0.15);
|
| 169 |
+
}
|
| 170 |
select option {
|
| 171 |
background: #0b152a;
|
| 172 |
color: var(--text);
|