from __future__ import annotations
import gradio as gr
from config import CONFIG
from assistant import bootstrap, chat, stt, tts, ui
UI = CONFIG.pack["ui"]
PROFILE_EXAMPLE = CONFIG.pack["profile_example"]
with gr.Blocks(title="Amigo") as demo:
state = gr.State([])
gr.HTML(
f'
{UI["title"]}
'
f'
{UI["subtitle"]}
'
)
avatar = gr.HTML(ui.avatar("idle"))
# The one action: a big tap-to-talk button right under the avatar. CSS turns
# Gradio's audio widget into a single round button (see assistant/ui.py).
mic = gr.Audio(
sources=["microphone"], type="filepath",
label=UI["mic"], elem_id="amigo-mic",
)
# Non-streaming: one clip per turn. A streaming=True output only plays on
# the first turn (Gradio reuses the component's MediaSource and never
# reinitializes it), so each turn sets a fresh full-reply clip instead.
reply_audio = gr.Audio(
label=UI["reply"], autoplay=True,
interactive=False, elem_id="amigo-reply",
)
# The transcript, as a reading aid. A placeholder fills the empty space.
chatbot = gr.Chatbot(
height=280, label="", elem_id="amigo-chat", show_label=False,
placeholder=UI["chat_placeholder"],
)
# Setup lives at the bottom, collapsed: the editable profile, prefilled with
# a neutral example. Nothing is predefined; it feeds every turn's prompt.
with gr.Accordion(UI["profile_label"], open=False):
gr.Markdown(UI["profile_help"])
profile_box = gr.Code(
value=PROFILE_EXAMPLE, language="yaml", label="",
elem_id="amigo-profile",
)
def on_listen():
"""Mic started: show the listening orb."""
return ui.avatar("listening")
def handle_turn(audio_path, history, profile_text):
"""Transcribe, stream the text live, then speak the full reply.
Text streams token-by-token so he sees the answer forming; the voice
plays once the reply is complete, which is a few seconds for a short
answer. `profile_text` is the YAML from the editor above. Dict-form
yields touch only the named components.
"""
history = history or []
yield {avatar: ui.avatar("thinking")}
user_text = stt.transcribe(audio_path)
if not user_text:
yield {avatar: ui.avatar("idle", UI["not_heard"])}
return
history = history + [{"role": "user", "content": user_text}]
history.append({"role": "assistant", "content": ""})
full = ""
for chunk in chat.respond(user_text, history[:-2], profile_text):
full += chunk
history[-1]["content"] = full
yield {chatbot: history, avatar: ui.avatar("talking")}
sr, samples = tts.synth(tts.clean_text(full))
yield {chatbot: history, reply_audio: (sr, samples),
avatar: ui.avatar("idle")}
mic.start_recording(on_listen, outputs=[avatar])
(
mic.stop_recording(
handle_turn,
inputs=[mic, state, profile_box],
outputs=[chatbot, reply_audio, avatar],
)
.then(lambda h: h, inputs=chatbot, outputs=state)
# Clear the mic so it drops its recorded clip and shows the big record
# button again. Without this the audio widget stays in playback/edit
# mode on the last take, and he can't start a new turn. Runs on every
# path (including "not heard"), so a failed turn never locks him out.
.then(lambda: gr.update(value=None), outputs=mic)
)
if __name__ == "__main__":
# On a Space this downloads the models on first boot; locally it's a no-op.
bootstrap.ensure_models()
# Warm the model + embeddings so the first turn isn't cold.
print("Calentando modelos…")
chat.llm.warmup()
chat.memory.warmup()
print("Listo.")
# Gradio 6: theme + css belong on launch().
demo.launch(theme=ui.THEME, css=ui.CSS)