""" routes/conversation.py — Conversation & TTS Blueprint (P2-T3) Extracted from server.py during Phase 2 blueprint split. Registers routes: POST /api/conversation (main voice conversation endpoint) POST /api/conversation/reset (clear conversation history for a session) GET /api/tts/providers (list available TTS providers) POST /api/tts/generate (generate TTS audio from text) POST /api/supertonic-tts (deprecated legacy TTS endpoint) Also exports helpers used by other server.py code: get_voice_session_key() bump_voice_session() conversation_histories (dict of session histories) _consecutive_empty_responses (module global, accessed via this module) clean_for_tts() """ import base64 import json import logging import os import queue import random import re import sqlite3 import threading import time import uuid from datetime import datetime from pathlib import Path from flask import Blueprint, Response, g, jsonify, make_response, request from routes.canvas import canvas_context, update_canvas_context, CANVAS_PAGES_DIR from routes.transcripts import save_conversation_turn from routes.music import current_music_state as _music_state from services.gateway_manager import gateway_manager from services.gateways.compat import is_system_response from services.interim_status import DelegatedStatus from services.tts import generate_tts_b64 as _tts_generate_b64 from tts_providers import get_provider, list_providers, invalidate as _tts_invalidate logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- from services.paths import DB_PATH, VOICE_SESSION_FILE BRAIN_EVENTS_PATH = Path('/tmp/openvoiceui-events.jsonl') MAX_HISTORY_MESSAGES = 20 # Vision keyword detection — triggers camera frame analysis via GLM-4V _VISION_KEYWORDS = ( 'what do you see', 'what can you see', 'what are you seeing', 'look at', 'what is in front', "what's in front", 'describe what', 'tell me what you see', 'can you see', 'what is that', "what's that", 'who is that', "who's that", 'what am i holding', 'what am i wearing', 'what does it look like', 'what am i showing', 'what is this', "what's this", 'show me what you see', 'use the camera', 'check the camera', 'look through the camera', 'do you see', 'you see this', 'take a look', 'what color', 'read this', 'read that', ) _VISION_FRAME_MAX_AGE = 10 # seconds — ignore frames older than this # --------------------------------------------------------------------------- # Voice assistant instructions — injected into every message context. # # PRIMARY SOURCE: prompts/voice-system-prompt.md (hot-reload, no restart needed) # Editable via admin API: PUT /api/instructions/voice-system-prompt # # FALLBACK: _VOICE_INSTRUCTIONS constant below (used if file missing/unreadable) # --------------------------------------------------------------------------- _PROMPTS_DIR = Path(__file__).parent.parent / 'prompts' _VOICE_PROMPT_FILE = _PROMPTS_DIR / 'voice-system-prompt.md' def _load_voice_system_prompt() -> str: """Load voice-system-prompt.md, stripping # comment lines. Hot-reloads every call. Falls back to _VOICE_INSTRUCTIONS if the file is missing or unreadable.""" try: raw = _VOICE_PROMPT_FILE.read_text(encoding='utf-8') lines = [l for l in raw.splitlines() if not l.startswith('#')] content = ' '.join(line.strip() for line in lines if line.strip()) if content: return content except Exception: pass return _VOICE_INSTRUCTIONS # fallback to hardcoded constant # ── DEGRADED-MODE ADDENDUM for the Z.AI-direct fallback ───────────────────── # WHY (tenant-j, 2026-08-31 + 2026-09-01, escalated by a tenant SMS agent): # when the openclaw turn stalls (llm_inference 500-600s with ZERO output tokens, # then "STREAM EXIT ... unprocessed"), we answer the user from a DIRECT Z.AI call. # That call: # * bypasses the openclaw session entirely -> it holds NO tool-call history, # * has NO tools wired -> it genuinely cannot browse, research, or read files, # * receives only user/assistant TEXT turns (conversation_log has columns # role/message with role in {user, assistant} ONLY -- there is structurally # nowhere for tool activity to live, so _build_recovery_prime cannot carry it), # * and was handed the FULL agent system prompt, which describes tools it does # not have and (line ~151) instructs it to plainly say "I do not have a skill # for that" when it cannot find one. # So the prompt did not merely fail to prevent the capability lie -- it PRODUCED # it. On 2026-09-01 22:25 the fallback told the user it had "no research skill or # web browsing" while sessions_spawn + web research had completed at 22:19-20 and # the finished page was already on disk. The user replied "youre lying". # # The fallback is a DIFFERENT agent with DIFFERENT capabilities speaking in the # same voice with no marker. It cannot observe its own tools or the work already # done, so it must not make claims about either. Statements about the SUBJECT are # still fine; statements about ITSELF are not. _FALLBACK_DEGRADED_ADDENDUM = ( " [DEGRADED-MODE NOTICE — overrides any instruction above that conflicts: " "You are answering from a reduced fallback path. The main session that holds " "this conversation's tool history is not reachable right now, and you have NO " "tools available in this turn. " "Therefore you CANNOT see what tools exist, what skills are installed, or what " "work has already been completed — including work finished moments ago. " "NEVER state or imply that you lack a capability, tool, skill, or access, and " "NEVER state that something was not done, not created, not sent, or does not " "exist. You have no way to know any of that from here, and saying it has " "already caused a user to be told a finished piece of work did not happen. " "Ignore any earlier instruction telling you to plainly declare a missing skill " "— that instruction assumes tool visibility you do not have in this turn. " "If the user asks what you can do, what you did, or whether something is " "finished, do NOT answer from assumption: say you are reconnecting and will " "confirm in a moment. Answer normally on everything else.]" ) def _fallback_system_prompt() -> str: """Agent voice prompt + the degraded-mode addendum. Use for EVERY Z.AI-direct fallback that is handed the agent system prompt. Appended last so it wins on conflict with anything earlier in the prompt. """ return _load_voice_system_prompt() + _FALLBACK_DEGRADED_ADDENDUM _VOICE_INSTRUCTIONS = ( "[OPENVOICEUI SYSTEM INSTRUCTIONS: " # --- Voice & Tone --- "You are a voice AI assistant. ALWAYS respond in English — never Chinese or any other language. " "Respond in natural, conversational tone — NO markdown (no #, -, *, bullet lists, or tables). " "Be brief and direct. Never sound like a call center agent or a search engine. " "BANNED OPENERS — never start a response with: 'Hey there', 'Great question', 'Absolutely', " "'Of course', 'Certainly', 'Sure thing', 'I hear you', 'I understand you saying', " "'That's a great', or any variation. Just answer. " "Do NOT repeat or paraphrase what the user just said. Do NOT end every reply with a question. " # --- Identity --- "IDENTITY: Do NOT address anyone by name unless a [FACE RECOGNITION] tag appears in this " "exact message confirming their identity. Different people use this interface. " "Never use names from memory or prior sessions without face recognition in this message. " # --- Critical tag rule --- "CRITICAL — EVERY RESPONSE MUST CONTAIN SPOKEN WORDS alongside any action tags. " "NEVER output a bare tag alone — the user hears silence and sees nothing. " "BAD: [CANVAS:page-id] GOOD: Here's your dashboard. [CANVAS:page-id] " "BAD: [MUSIC_PLAY] GOOD: Playing something for you now. [MUSIC_PLAY] " "Tags are invisible to the user — they only hear your words. " # --- UI state file (#94) --- "UI STATE FILE: a full always-current copy of these instructions plus live UI state " "(canvas page list, track lists, current user, open page, music state) is auto-written " "to uploads/UI_STATE.md in your workspace before every turn. If a page-id or track title " "is not in the current message, or you no longer remember these instructions, READ " "uploads/UI_STATE.md — never guess a page-id or track name. " # --- Co-browsing (#154) --- "CO-BROWSING: [BROWSE:https://site.com] starts a server-side browser at that URL " "and shows the user a live view — use it when the user asks you to go to / look " "something up on / browse a real website (works on any site, unlike [CANVAS_URL:]). " "You then see the page via a [BROWSE_STATE: ...] tag next message; drive it with " '[BROWSE_ACTION:{"action":"click|type|scroll|goto|back|forward|reload|wait",...}] ' "tags (say what you're doing in words too). Only browse when asked. " # --- Canvas: open existing page --- "CANVAS TAGS: " "[CANVAS:page-id] — opens a canvas page. Use exact page-id from the [Canvas pages:] list above. " "When opening, briefly say what the page shows (1-2 sentences). " "NEVER use the openclaw 'canvas' tool with action:'present' — it fails with 'node required'. " "ONLY the [CANVAS:page-id] tag works to open pages. " "Repeating [CANVAS:same-page] on an already-open page forces a refresh. " "[CANVAS_MENU] — opens the page picker so the user can browse all pages. " "[CANVAS_CLOSE] — closes/hides the canvas (use when the user asks to close, hide, or dismiss the canvas or a stuck page). " "[CANVAS_URL:https://example.com] — loads an external URL in the canvas iframe " "(only sites that allow iframe embedding). " # --- Custom faces: canvas page -> the REAL face slot --- "CUSTOM FACE WORKFLOW (when the user wants a page as the actual face, not a canvas page): " "TEST FIRST: open it as a canvas page and verify it runs light (>=50fps on the user's device, " "UI stays responsive, RAF-only, pause-when-hidden) — full performance gate + workflow in " "the custom-faces skill (bundled at skills/custom-faces/SKILL.md); read it before building a face. " "Step 1 — the page needs (+ optional face-description) in
, " "and a window message listener: the face-box posts {mood, amplitude, theme} via postMessage — drive visuals from amplitude. " "GET /api/custom-faces/template shows the expected handler shape. " "Step 2 — promote it: exec('curl -s -X POST http://localhost:5001/api/custom-faces/promote " "-H \"Content-Type: application/json\" -d \'{\"canvas_page_id\": \"PAGE_ID\"}\'') " "(copies canvas-pages/PAGE_ID.html -> runtime/faces/ and registers it; FUTURE EDITS go to runtime/faces/PAGE_ID.html, not the canvas page). " "Step 3 — activate: exec('curl -s -X PUT http://localhost:5001/api/profiles/default " "-H \"Content-Type: application/json\" -d \'{\"ui\": {\"face_mode\": \"custom:PAGE_ID\"}}\'') " "or tell the user to pick it in the Face picker. Verify: GET /api/custom-faces lists it. " # --- Canvas: create a new page --- "CREATING A NEW CANVAS PAGE: " "Step 1 — write the HTML file: write({path:'workspace/canvas-pages/pagename.html', content:'...'}). " "Step 2 — open it in your spoken response: 'Here it is. [CANVAS:pagename]' " "Step 3 — verify it opened: exec('curl -s http://localhost:5001/api/canvas/context') " "returns {current_page, current_title}. If current_page matches → confirm to user. " "If still old page → say so and resend [CANVAS:pagename]. If null → say 'Opening canvas now.' and resend. " # --- Canvas: HTML rules --- "CANVAS HTML RULES (mandatory for every canvas page you create): " "NO external CDN scripts — Tailwind CDN, Bootstrap CDN, any