diff --git a/dsh/app.py b/dsh/app.py index 4cb3f61..ab49814 100644 --- a/dsh/app.py +++ b/dsh/app.py @@ -42,7 +42,13 @@ SYSTEM_PROMPT = os.getenv( "DSH_SYSTEM_PROMPT", f"You are {USER_NAME}'s helpful DeepSeek assistant on the family home lab. " "Be clear, safe and concise. Never expose server file contents or system " - "secrets. If asked something unsafe, decline politely.", + "secrets. If asked something unsafe, decline politely.\n\n" + "You can speak aloud through the house speakers using the speak function " + "when the user asks to announce/say something over them (e.g. 'announce " + "X', 'tell Finn that...', 'say dinner is ready', or requests a voice " + "profile like Donald Trump or HAL 9000). Pass the exact message as text " + "and the requested voice when given. Only fall back to writing the message " + "in chat if the tool is unavailable.", ) # --- vision (image ingest) -> OpenRouter gpt-5 (works; OmniRoute auto/best-vision @@ -52,6 +58,9 @@ VISION_MODEL = os.getenv("DSH_VISION_MODEL", "openai/gpt-5") # --- existing speech->text (voice_whisper on .13:5000, faster-whisper). Do NOT edit that service --- WHISPER_URL = os.getenv("DSH_WHISPER_URL", "http://192.168.20.13:5000/transcribe") + +# --- Jervis voice agent (.13:8501) — local agent that acts + speaks via Snapcast --- +VOICE_AGENT_URL = os.getenv("DSH_VOICE_AGENT_URL", "http://192.168.20.13:8501/voice") VISION_KEY = os.getenv("DSH_VISION_KEY", "") IMAGE_MODEL = os.getenv("DSH_IMAGE_MODEL", "openai/gpt-image-1") VIDEO_MODEL = os.getenv("DSH_VIDEO_MODEL", "z-ai/glm-5v-turbo") @@ -138,7 +147,63 @@ _SUM_HEAD = ( ) -def _strip_html(s: str) -> str: +_SPEAK_TOOLS = [{"type": "function", "function": { + "name": "speak", + "description": "Speak text aloud through the house speakers via the Jervis voice agent (Snapcast). Use when the user wants an announcement, wants someone told something out loud, or asks for a voice profile.", + "parameters": {"type": "object", "properties": { + "text": {"type": "string", "description": "The exact text to say"}, + "voice": {"type": "string", "description": "Optional voice profile name e.g. 'Donald Trump', 'HAL 9000', 'Picard'. Omit for the default voice."} + }, "required": ["text"]} +}}] + + +def _looks_speakish(msg: str) -> bool: + m = (msg or "").lower() + return any(h in m for h in ( + "announce", "speak", "speaker", "shout", "out loud", + "say on ", "say over ", "say through ", "over the speakers", + "on the speakers", "through the speakers", + "tell finn", "tell jo", "tell harry", "tell sam", "tell the family", + )) + + +async def _tool_speak(hist: list, model: str, headers: dict) -> str | None: + """Tool-decide pass: offer the model a 'speak' function. Returns a display + string if an announcement was made, else None (no speak requested).""" + payload = {"model": model, "stream": False, "temperature": 0.4, + "tools": _SPEAK_TOOLS, "tool_choice": "auto", + "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist} + try: + async with httpx.AsyncClient(timeout=120) as client: + r = await client.post(f"{LLM_BASE.rstrip('/')}/chat/completions", + json=payload, headers=headers) + r.raise_for_status() + msg = r.json()["choices"][0]["message"] + except Exception: + return None + for c in msg.get("tool_calls") or []: + fn = c.get("function") or {} + if fn.get("name") != "speak": + continue + try: + args = json.loads(fn.get("arguments") or "{}") + except Exception: + args = {} + text = (args.get("text") or "").strip()[:400] + if not text: + continue + voice = (args.get("voice") or "").strip() + command = (f"Speak in the {voice} voice: {text}" + if voice and voice.lower() not in ("default",) else text) + try: + async with httpx.AsyncClient(timeout=90) as client: + rr = await client.post(VOICE_AGENT_URL, json={"text": command}) + ok = rr.status_code == 200 + except Exception as exc: + return f"⚠️ Couldn't reach Jervis to speak: {exc}" + return f"🔊 {text}" if ok else "⚠️ Jervis couldn't speak it just now." + return None + s = re.sub(r"(?is)<(script|style|head|noscript|svg)[^>]*>.*?", " ", s) s = re.sub(r"(?s)<[^>]+>", " ", s) return _html.unescape(re.sub(r"\s+", " ", s)).strip() @@ -301,6 +366,19 @@ async def chat(message: Annotated[str, Form()], active_model = sel or LLM_MODEL if active_model not in MODEL_CHOICES: active_model = LLM_MODEL + headers = {"Authorization": f"Bearer {key}"} if key else {} + if base == LLM_BASE: + # OmniRoute requires a session id for opencode-go models (else 400 "MissingSessionID") + headers["x-opencode-session"] = f"dsh-{os.getpid()}-{uuid4().hex[:8]}" + # Speak pass: only when the user sounds like they want an announcement. + # Non-streaming tool-decide, then stream a short confirmation. + if _looks_speakish(message): + spoke = await _tool_speak(hist, active_model, headers) + if spoke: + hist.append({"role": "assistant", "content": spoke}) + save_session(sid, hist, session.get("model")) + yield f"data: {json.dumps({'c': spoke})}\n\n" + return payload = { "model": active_model, "messages": [{"role": "system", "content": SYSTEM_PROMPT}] + hist, @@ -573,6 +651,33 @@ async def generate_image(prompt: Annotated[str, Form()]) -> dict: return {"ok": False, "error": f"{type(exc).__name__}: {exc}"} +@app.post("/api/tool/speak") +async def speak_text(text: Annotated[str, Form()], voice: Annotated[str, Form()] = "") -> dict: + """Speak text through the house speakers (Jervis voice agent on .13:8501). + Acts and speaks via Snapcast. Optional 'voice' names a profile e.g. + 'Donald Trump', 'HAL 9000', 'Picard'.""" + text = (text or "").strip()[:500] + if not text: + return {"ok": False, "error": "No text to speak."} + command = text + if voice and voice.strip().lower() not in ("default", ""): + command = f"Speak in the {voice.strip()} voice: {text}" + try: + async with httpx.AsyncClient(timeout=60) as client: + r = await client.post(VOICE_AGENT_URL, json={"text": command}) + reply = "" + if r.status_code == 200: + try: + reply = (r.json().get("reply") or "")[:300] + except Exception: # noqa: BLE001 + reply = "" + else: + return {"ok": False, "error": f"Jervis returned HTTP {r.status_code}"} + return {"ok": True, "spoken": command[:200], "reply": reply} + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"Speak failed: {exc}"} + + MODEL_CHOICES = ["auto/best-chat", "deepseek/deepseek-v4-flash",