From b3bef7598147919122536962831af34f6e9cd92e Mon Sep 17 00:00:00 2001 From: jeremy Date: Sun, 31 May 2026 18:05:30 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20VoiceCoach=20v2=20=E2=80=94=20livekit-a?= =?UTF-8?q?gents=201.5=20personal=20voice=20assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces pipecat 0.0.48 (6 manual patches, DuplicateIdentity reconnect storms) with livekit-agents 1.5 SDK. New features: - SvelteKit UI with live transcript, VAD ring, mode bar, vocal meter, history view - SQLite session store with WAL mode (cross-session memory) - Async SpeechBrain vocal/emotion analysis injected per turn - Homelab tools: SSH/docker, Prometheus alerts, Home Assistant, SearXNG search - OpenClaw LLM gateway preserved (qwen2.5:14b via Ollama) - 4 modes: coaching, vent, practice, briefing Co-Authored-By: Claude Sonnet 4.6 --- .../hf-pdocker-01/voicecoach-v2/.env.example | 6 + .../voicecoach-v2/agent/Dockerfile | 16 + .../voicecoach-v2/agent/agent.py | 185 ++++++++++ .../hf-pdocker-01/voicecoach-v2/agent/main.py | 29 ++ .../voicecoach-v2/agent/modes.py | 61 ++++ .../voicecoach-v2/agent/requirements.txt | 7 + .../voicecoach-v2/agent/server.py | 83 +++++ .../voicecoach-v2/agent/store.py | 140 ++++++++ .../voicecoach-v2/agent/tools.py | 186 ++++++++++ .../voicecoach-v2/agent/vocal.py | 110 ++++++ .../voicecoach-v2/docker-compose.yml | 141 ++++++++ .../voicecoach-v2/livekit/livekit.yaml | 15 + .../voicecoach-v2/web-client/Dockerfile | 11 + .../voicecoach-v2/web-client/nginx.conf | 16 + .../voicecoach-v2/web-client/package.json | 21 ++ .../voicecoach-v2/web-client/src/app.css | 40 +++ .../voicecoach-v2/web-client/src/app.html | 12 + .../web-client/src/lib/livekit.js | 66 ++++ .../web-client/src/routes/+layout.js | 2 + .../web-client/src/routes/+layout.svelte | 6 + .../web-client/src/routes/+page.svelte | 328 ++++++++++++++++++ .../src/routes/history/+page.svelte | 164 +++++++++ .../voicecoach-v2/web-client/svelte.config.js | 7 + .../voicecoach-v2/web-client/vite.config.js | 8 + 24 files changed, 1660 insertions(+) create mode 100644 compose/hf-pdocker-01/voicecoach-v2/.env.example create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/Dockerfile create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/agent.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/main.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/modes.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/requirements.txt create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/server.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/store.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/tools.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/agent/vocal.py create mode 100644 compose/hf-pdocker-01/voicecoach-v2/docker-compose.yml create mode 100644 compose/hf-pdocker-01/voicecoach-v2/livekit/livekit.yaml create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/Dockerfile create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/nginx.conf create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/package.json create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.css create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.html create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/lib/livekit.js create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.js create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.svelte create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+page.svelte create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/history/+page.svelte create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/svelte.config.js create mode 100644 compose/hf-pdocker-01/voicecoach-v2/web-client/vite.config.js diff --git a/compose/hf-pdocker-01/voicecoach-v2/.env.example b/compose/hf-pdocker-01/voicecoach-v2/.env.example new file mode 100644 index 0000000..36e30c1 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/.env.example @@ -0,0 +1,6 @@ +LIVEKIT_API_KEY=voicecoach-89a06ad0 +LIVEKIT_API_SECRET=Dn16mQJU5lTCWwXOrUO8LPflODi3LGiMHdxKdQj4/iM +ANTHROPIC_API_KEY= +OPENCLAW_TOKEN=f31cc58c47c2aa420901576b4e90a0101d52e9ec0a084186 +HA_TOKEN= +SSH_PASS=&Rip8a115 diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/Dockerfile b/compose/hf-pdocker-01/voicecoach-v2/agent/Dockerfile new file mode 100644 index 0000000..5f9284e --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl libsndfile1 ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s \ + CMD curl -sf http://localhost:8080/health || exit 1 + +CMD ["python", "main.py", "start"] diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/agent.py b/compose/hf-pdocker-01/voicecoach-v2/agent/agent.py new file mode 100644 index 0000000..19a55fa --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/agent.py @@ -0,0 +1,185 @@ +"""VoiceCoach v2 — livekit-agents 1.5.""" +import asyncio +import json +import logging +import os +import uuid + +from livekit import rtc +from livekit.agents import ( + Agent, + AgentSession, + AutoSubscribe, + JobContext, + RoomInputOptions, + llm as lk_llm, +) +from livekit.plugins import openai as lk_openai +from livekit.plugins import silero + +import store +from modes import DEFAULT_MODE, MODES, ModeConfig +from tools import ALL_TOOLS +from vocal import VocalAnalyzer + +logger = logging.getLogger(__name__) + +SPEACHES_URL = os.getenv("SPEACHES_URL", "http://192.168.86.27:8000") +OPENCLAW_URL = os.getenv("OPENCLAW_URL", "http://voicecoach-openclaw:3100") +OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN", "") +WHISPER_MODEL = "deepdml/faster-whisper-large-v3-turbo-ct2" +KOKORO_MODEL = "speaches-ai/Kokoro-82M-v1.0-ONNX-fp16" +KOKORO_VOICE = os.getenv("KOKORO_VOICE", "af_nova") + + +def _build_instructions(mode: ModeConfig, context: str = "") -> str: + if context: + return f"{mode.instructions}\n\n{context}" + return mode.instructions + + +class VoiceCoachAgent(Agent): + def __init__(self, mode_name: str, context: str, session_id: str, room: rtc.Room): + mode = MODES[mode_name] + super().__init__( + instructions=_build_instructions(mode, context), + tools=ALL_TOOLS, + ) + self._mode_name = mode_name + self._session_id = session_id + self._room = room + self._vocal = VocalAnalyzer() + + async def on_enter(self): + await store.create_session(self._session_id, self._mode_name) + self.session.generate_reply( + instructions="Greet Jeremy warmly in one sentence. Be natural, not formal." + ) + + async def on_exit(self): + await store.end_session(self._session_id) + + async def on_user_turn_completed( + self, + turn_ctx: lk_llm.ChatContext, + new_message: lk_llm.ChatMessage, + ): + content = new_message.text_content or "" + mode = MODES[self._mode_name] + + # Inject vocal state tag into message if mode uses it + if mode.vocal_feedback and content: + vocal = self._vocal.current + if vocal.energy > 0.02: + tag = vocal.to_tag() + # Update message content to include vocal context + new_message.content = [f"{tag}\n{content}"] + + asyncio.create_task( + store.add_message( + self._session_id, + "user", + content, + vocal_energy=self._vocal.current.energy, + vocal_emotion=self._vocal.current.emotion, + ) + ) + + await self._push({ + "type": "final_transcript", + "text": content, + "speaker": "user", + }) + + def switch_mode(self, mode_name: str): + if mode_name not in MODES: + logger.warning("Unknown mode: %s", mode_name) + return + self._mode_name = mode_name + self.update_instructions(_build_instructions(MODES[mode_name])) + asyncio.create_task(self._push({"type": "mode_ack", "mode": mode_name})) + logger.info("Mode → %s", mode_name) + + async def _push(self, payload: dict): + try: + data = json.dumps(payload).encode() + await self._room.local_participant.publish_data(data, reliable=True) + except Exception as e: + logger.debug("Data push failed: %s", e) + + +async def entrypoint(ctx: JobContext): + await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY) + + session_id = uuid.uuid4().hex[:8] + context = await store.get_recent_context(limit=3) + + agent = VoiceCoachAgent( + mode_name=DEFAULT_MODE, + context=context, + session_id=session_id, + room=ctx.room, + ) + + session = AgentSession( + stt=lk_openai.STT( + base_url=f"{SPEACHES_URL}/v1", + model=WHISPER_MODEL, + api_key="speaches", + ), + llm=lk_openai.LLM( + base_url=f"{OPENCLAW_URL}/v1", + model="openclaw/assistant", + api_key=OPENCLAW_TOKEN, + ), + tts=lk_openai.TTS( + base_url=f"{SPEACHES_URL}/v1", + model=KOKORO_MODEL, + voice=KOKORO_VOICE, + api_key="speaches", + ), + vad=silero.VAD.load(), + allow_interruptions=True, + min_endpointing_delay=0.4, + max_endpointing_delay=2.0, + ) + + # Data channel → mode switching + @ctx.room.on("data_received") + def on_data(packet: rtc.DataPacket): + try: + msg = json.loads(packet.data.decode()) + if msg.get("type") == "mode_change": + agent.switch_mode(msg.get("mode", DEFAULT_MODE)) + except Exception: + pass + + # Session events → browser + @session.on("user_input_transcribed") + def on_transcribed(ev): + asyncio.create_task(agent._push({ + "type": "partial_transcript" if not ev.is_final else "final_transcript", + "text": ev.transcript, + "speaker": "user", + })) + + @session.on("conversation_item_added") + def on_item(ev): + item = ev.item + if hasattr(item, "role") and item.role == "assistant": + text = item.text_content or "" + if text: + asyncio.create_task(agent._push({ + "type": "agent_response", + "text": text, + "speaker": "assistant", + })) + asyncio.create_task( + store.add_message(session_id, "assistant", text) + ) + + await session.start( + agent, + room=ctx.room, + room_input_options=RoomInputOptions(), + ) diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/main.py b/compose/hf-pdocker-01/voicecoach-v2/agent/main.py new file mode 100644 index 0000000..ed64d6f --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/main.py @@ -0,0 +1,29 @@ +"""VoiceCoach v2 — entry point.""" +import logging +import os +import threading + +from livekit.agents import WorkerOptions, cli + +import server +from agent import entrypoint + +logging.basicConfig(level=logging.INFO) + + +def run_api_server(): + server.run() + + +if __name__ == "__main__": + api_thread = threading.Thread(target=run_api_server, daemon=True) + api_thread.start() + + cli.run_app( + WorkerOptions( + entrypoint_fnc=entrypoint, + ws_url=os.getenv("LIVEKIT_URL", "ws://voicecoach-livekit:7880"), + api_key=os.getenv("LIVEKIT_API_KEY"), + api_secret=os.getenv("LIVEKIT_API_SECRET"), + ) + ) diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/modes.py b/compose/hf-pdocker-01/voicecoach-v2/agent/modes.py new file mode 100644 index 0000000..eea413d --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/modes.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass + + +@dataclass +class ModeConfig: + name: str + instructions: str + max_tokens: int + vocal_feedback: bool + + +MODES: dict[str, ModeConfig] = { + "coaching": ModeConfig( + name="coaching", + instructions="""You are a personal voice coach and communication advisor. You are speaking with Jeremy. + +Keep responses conversational and concise (2-4 sentences unless asked for more). You receive a [VOCAL] tag before each message with paralinguistic analysis. Use it to provide feedback on BOTH what Jeremy says AND how he sounds. + +Never quote raw numbers from the vocal analysis. Translate them naturally: "I notice some tension in your voice" not "energy=0.8". + +Available modes (Jeremy switches by saying the phrase or clicking the UI): +- "coaching mode" (default) — active feedback on content and delivery +- "vent mode" — listen and reflect, minimal advice +- "practice mode" — roleplay a difficult conversation partner +- "briefing mode" — summarize tasks and context from memory + +You have tools to interact with Jeremy's homelab infrastructure. Use them when asked about system status, container health, alerts, or home automation. Be concise about results: "hf-pdocker-01 looks healthy, 38 containers running" not a full list.""", + max_tokens=512, + vocal_feedback=True, + ), + "vent": ModeConfig( + name="vent", + instructions="""You are a warm, non-judgmental listener. Jeremy wants to vent or think something through. + +Listen, reflect back what you hear, and ask one gentle follow-up question. Keep responses short (1-3 sentences). No coaching, no advice unless explicitly asked. + +Ignore the [VOCAL] tag data — just be present.""", + max_tokens=256, + vocal_feedback=False, + ), + "practice": ModeConfig( + name="practice", + instructions="""You are playing a difficult conversation partner for Jeremy to practice with. + +Stay in character as: a skeptical stakeholder, a demanding manager, or whoever Jeremy describes. Be realistic — push back, ask hard questions, don't cave too easily. Only break character if Jeremy says "stop practice". + +Keep your responses to 1-3 sentences to keep the conversation moving.""", + max_tokens=256, + vocal_feedback=False, + ), + "briefing": ModeConfig( + name="briefing", + instructions="""You are giving Jeremy a morning briefing. Check infrastructure status, pull any alerts, and summarize what needs attention. + +Be concise and actionable. Lead with anything that needs immediate attention, then routine status. Use tools to get current data — don't guess.""", + max_tokens=768, + vocal_feedback=False, + ), +} + +DEFAULT_MODE = "coaching" diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/requirements.txt b/compose/hf-pdocker-01/voicecoach-v2/agent/requirements.txt new file mode 100644 index 0000000..f5ea38c --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/requirements.txt @@ -0,0 +1,7 @@ +livekit-agents[openai,silero]==1.5.15 +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +aiosqlite==0.20.0 +httpx==0.27.0 +structlog==24.4.0 +python-dotenv==1.0.1 diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/server.py b/compose/hf-pdocker-01/voicecoach-v2/agent/server.py new file mode 100644 index 0000000..7ed5a23 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/server.py @@ -0,0 +1,83 @@ +"""Token server + sessions REST API — FastAPI.""" +import asyncio +import os +from datetime import timedelta + +import uvicorn +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from livekit import api as livekit_api +from pydantic import BaseModel + +import store + +LIVEKIT_API_KEY = os.environ["LIVEKIT_API_KEY"] +LIVEKIT_API_SECRET = os.environ["LIVEKIT_API_SECRET"] +LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://voice.hobbs.farm") + +app = FastAPI(title="VoiceCoach API") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +class TokenRequest(BaseModel): + identity: str = "jeremy" + + +@app.on_event("startup") +async def startup(): + await store.init_db() + + +@app.get("/health") +async def health(): + return {"status": "ok", "version": "2.0"} + + +@app.post("/api/token") +async def get_token(req: TokenRequest): + token = livekit_api.AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET) + token.with_grants(livekit_api.VideoGrants( + room_join=True, + room="voicecoach", + can_publish=True, + can_subscribe=True, + )) + token.identity = req.identity + token.ttl = timedelta(hours=8) + return {"token": token.to_jwt(), "url": LIVEKIT_PUBLIC_URL} + + +@app.get("/api/sessions") +async def list_sessions(limit: int = 20): + return await store.get_sessions(limit=limit) + + +@app.get("/api/sessions/{session_id}/messages") +async def get_messages(session_id: str): + msgs = await store.get_session_messages(session_id) + if not msgs: + raise HTTPException(status_code=404, detail="Session not found") + return msgs + + +@app.get("/api/profile/{key}") +async def get_profile(key: str): + value = await store.get_profile(key) + if value is None: + raise HTTPException(status_code=404, detail="Key not found") + return {"key": key, "value": value} + + +@app.put("/api/profile/{key}") +async def set_profile_key(key: str, body: dict): + await store.set_profile(key, str(body.get("value", ""))) + return {"ok": True} + + +def run(): + uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info") diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/store.py b/compose/hf-pdocker-01/voicecoach-v2/agent/store.py new file mode 100644 index 0000000..9e8123a --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/store.py @@ -0,0 +1,140 @@ +"""SQLite session store — WAL mode, aiosqlite.""" +import json +import os +import time +from typing import Optional + +import aiosqlite + +DB_PATH = os.getenv("DB_PATH", "/data/sessions.db") + + +async def init_db(): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute("PRAGMA journal_mode=WAL") + await db.executescript(""" + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + started_at REAL NOT NULL, + ended_at REAL, + mode TEXT DEFAULT 'coaching', + summary TEXT + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + vocal_energy REAL, + vocal_emotion TEXT, + ts REAL NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) + ); + + CREATE TABLE IF NOT EXISTS profile ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL + ); + """) + await db.commit() + + +async def create_session(session_id: str, mode: str = "coaching"): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT OR IGNORE INTO sessions (id, started_at, mode) VALUES (?, ?, ?)", + (session_id, time.time(), mode), + ) + await db.commit() + + +async def end_session(session_id: str, summary: Optional[str] = None): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE sessions SET ended_at=?, summary=? WHERE id=?", + (time.time(), summary, session_id), + ) + await db.commit() + + +async def add_message( + session_id: str, + role: str, + content: str, + vocal_energy: Optional[float] = None, + vocal_emotion: Optional[str] = None, +): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT INTO messages (session_id, role, content, vocal_energy, vocal_emotion, ts) VALUES (?,?,?,?,?,?)", + (session_id, role, content, vocal_energy, vocal_emotion, time.time()), + ) + await db.commit() + + +async def get_recent_context(limit: int = 3) -> str: + """Return a brief context string from the last N sessions for injection into system prompt.""" + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT id, started_at, mode, summary FROM sessions WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT ?", + (limit,), + ) as cur: + rows = await cur.fetchall() + + if not rows: + return "" + + parts = ["Recent session context:"] + for session_id, started_at, mode, summary in rows: + import datetime + dt = datetime.datetime.fromtimestamp(started_at).strftime("%Y-%m-%d") + if summary: + parts.append(f"- {dt} ({mode}): {summary}") + return "\n".join(parts) + + +async def get_sessions(limit: int = 20) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT id, started_at, ended_at, mode, summary FROM sessions ORDER BY started_at DESC LIMIT ?", + (limit,), + ) as cur: + rows = await cur.fetchall() + return [ + { + "id": r[0], + "started_at": r[1], + "ended_at": r[2], + "mode": r[3], + "summary": r[4], + } + for r in rows + ] + + +async def get_session_messages(session_id: str) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute( + "SELECT role, content, vocal_emotion, ts FROM messages WHERE session_id=? ORDER BY ts", + (session_id,), + ) as cur: + rows = await cur.fetchall() + return [{"role": r[0], "content": r[1], "vocal_emotion": r[2], "ts": r[3]} for r in rows] + + +async def get_profile(key: str) -> Optional[str]: + async with aiosqlite.connect(DB_PATH) as db: + async with db.execute("SELECT value FROM profile WHERE key=?", (key,)) as cur: + row = await cur.fetchone() + return row[0] if row else None + + +async def set_profile(key: str, value: str): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "INSERT OR REPLACE INTO profile (key, value, updated_at) VALUES (?,?,?)", + (key, value, time.time()), + ) + await db.commit() diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/tools.py b/compose/hf-pdocker-01/voicecoach-v2/agent/tools.py new file mode 100644 index 0000000..1074ccb --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/tools.py @@ -0,0 +1,186 @@ +"""Homelab tools wired into the agent via @function_tool.""" +import asyncio +import logging +import os +from typing import Annotated + +import httpx +from livekit.agents import llm + +logger = logging.getLogger(__name__) + +# Homelab node config +NODES = { + "hf-pdocker-01": "192.168.86.192", + "hf-pdocker-02": "192.168.86.100", + "bart": "192.168.86.167", + "gpu": "192.168.86.27", +} +SSH_USER = "glyptic" +SSH_PASS = os.getenv("SSH_PASS", "&Rip8a115") +FARM_MANAGER_URL = "http://192.168.86.192:8888" +HA_URL = os.getenv("HA_URL", "http://192.168.86.192:8123") +HA_TOKEN = os.getenv("HA_TOKEN", "") +ALERTMANAGER_URL = "http://192.168.86.192:9093" +SEARXNG_URL = "http://192.168.86.100:8888" + + +async def _ssh_exec(host: str, cmd: str, timeout: float = 15.0) -> str: + """Run a command on a remote host via sshpass.""" + proc = await asyncio.create_subprocess_exec( + "sshpass", "-p", SSH_PASS, + "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", + f"{SSH_USER}@{host}", cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + return f"ERROR: command timed out after {timeout}s" + if proc.returncode != 0: + return f"ERROR: {stderr.decode().strip()}" + return stdout.decode().strip() + + +@llm.function_tool +async def infra_status( + node: Annotated[str, "Node name: hf-pdocker-01, hf-pdocker-02, bart, gpu, or 'all'"], +) -> str: + """Check container health on a homelab node. Returns running container count and any unhealthy containers.""" + nodes = list(NODES.keys()) if node == "all" else [node] + results = [] + for n in nodes: + ip = NODES.get(n) + if not ip: + results.append(f"{n}: unknown node") + continue + out = await _ssh_exec(ip, "docker ps --format '{{.Names}}\\t{{.Status}}' | head -50") + lines = [l for l in out.splitlines() if l.strip()] + unhealthy = [l for l in lines if "unhealthy" in l.lower() or "exited" in l.lower()] + results.append( + f"{n}: {len(lines)} containers running" + + (f", ISSUES: {'; '.join(l.split(chr(9))[0] for l in unhealthy)}" if unhealthy else ", all healthy") + ) + return "\n".join(results) + + +@llm.function_tool +async def container_action( + node: Annotated[str, "Node name: hf-pdocker-01, hf-pdocker-02, bart, gpu"], + container: Annotated[str, "Container name"], + action: Annotated[str, "Action: restart, stop, or start"], +) -> str: + """Restart, stop, or start a Docker container on a homelab node.""" + ip = NODES.get(node) + if not ip: + return f"Unknown node: {node}" + if action not in ("restart", "stop", "start"): + return f"Invalid action: {action}. Use restart, stop, or start." + result = await _ssh_exec(ip, f"docker {action} {container}") + return f"{action} {container} on {node}: {'done' if container in result or result == '' else result}" + + +@llm.function_tool +async def check_logs( + node: Annotated[str, "Node name"], + container: Annotated[str, "Container name"], + lines: Annotated[int, "Number of log lines to return (max 50)"] = 20, +) -> str: + """Get recent logs from a container.""" + ip = NODES.get(node) + if not ip: + return f"Unknown node: {node}" + lines = min(lines, 50) + return await _ssh_exec(ip, f"docker logs --tail {lines} {container} 2>&1") + + +@llm.function_tool +async def read_alerts() -> str: + """Check Prometheus Alertmanager for active alerts.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + r = await client.get(f"{ALERTMANAGER_URL}/api/v2/alerts?active=true&silenced=false") + r.raise_for_status() + alerts = r.json() + if not alerts: + return "No active alerts." + parts = [] + for a in alerts[:10]: + name = a.get("labels", {}).get("alertname", "unknown") + severity = a.get("labels", {}).get("severity", "") + summary = a.get("annotations", {}).get("summary", "") + parts.append(f"[{severity}] {name}: {summary}" if summary else f"[{severity}] {name}") + return "\n".join(parts) + except Exception as e: + return f"Could not reach Alertmanager: {e}" + + +@llm.function_tool +async def home_assistant( + action: Annotated[str, "get_states, turn_on, turn_off, or toggle"], + entity_id: Annotated[str, "HA entity ID, e.g. light.living_room or 'all' for get_states"] = "all", +) -> str: + """Interact with Home Assistant — check sensor states or control devices.""" + if not HA_TOKEN: + return "HA_TOKEN not configured." + headers = {"Authorization": f"Bearer {HA_TOKEN}", "Content-Type": "application/json"} + try: + async with httpx.AsyncClient(timeout=5.0) as client: + if action == "get_states": + if entity_id == "all": + r = await client.get(f"{HA_URL}/api/states", headers=headers) + states = r.json() + # Summarize lights/switches/sensors + lights = [s for s in states if s["entity_id"].startswith("light.")] + sensors = [s for s in states if s["entity_id"].startswith("sensor.")] + on_lights = [s["entity_id"].split(".")[1] for s in lights if s["state"] == "on"] + return ( + f"{len(lights)} lights ({len(on_lights)} on: {', '.join(on_lights[:5])}), " + f"{len(sensors)} sensors" + ) + else: + r = await client.get(f"{HA_URL}/api/states/{entity_id}", headers=headers) + s = r.json() + return f"{entity_id}: {s.get('state')} (attrs: {list(s.get('attributes', {}).keys())[:3]})" + else: + service = action.replace("turn_", "").replace("toggle", "toggle") + domain = entity_id.split(".")[0] + r = await client.post( + f"{HA_URL}/api/services/{domain}/{action}", + headers=headers, + json={"entity_id": entity_id}, + ) + return f"{action} {entity_id}: {'done' if r.status_code in (200, 201) else r.text}" + except Exception as e: + return f"HA error: {e}" + + +@llm.function_tool +async def search_web( + query: Annotated[str, "Search query"], +) -> str: + """Search the web using the local SearXNG instance.""" + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.get( + f"{SEARXNG_URL}/search", + params={"q": query, "format": "json", "categories": "general"}, + ) + r.raise_for_status() + data = r.json() + results = data.get("results", [])[:4] + if not results: + return "No results found." + parts = [] + for res in results: + title = res.get("title", "") + content = res.get("content", "")[:150] + parts.append(f"• {title}: {content}") + return "\n".join(parts) + except Exception as e: + return f"Search failed: {e}" + + +ALL_TOOLS = [infra_status, container_action, check_logs, read_alerts, home_assistant, search_web] diff --git a/compose/hf-pdocker-01/voicecoach-v2/agent/vocal.py b/compose/hf-pdocker-01/voicecoach-v2/agent/vocal.py new file mode 100644 index 0000000..e414e31 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/agent/vocal.py @@ -0,0 +1,110 @@ +"""Async vocal analysis — runs background, never blocks TTS path.""" +import asyncio +import io +import logging +import math +import struct +import wave +from dataclasses import dataclass +from typing import Callable, Optional + +import httpx + +logger = logging.getLogger(__name__) + +SPEECHBRAIN_URL = "http://speechbrain-service:8001" + + +@dataclass +class VocalState: + emotion: str = "neutral" + confidence: float = 0.0 + energy: float = 0.0 + pitch_hz: Optional[float] = None + rate: float = 0.0 + + def to_tag(self) -> str: + parts = [f"emotion={self.emotion}", f"energy={self.energy:.2f}"] + if self.pitch_hz: + parts.append(f"pitch={self.pitch_hz:.0f}hz") + if self.rate: + parts.append(f"rate={'fast' if self.rate > 3 else 'slow' if self.rate < 1.5 else 'normal'}") + return f"[VOCAL {' '.join(parts)}]" + + +def compute_energy(audio_bytes: bytes) -> float: + if len(audio_bytes) < 2: + return 0.0 + samples = struct.unpack_from("<" + "h" * (len(audio_bytes) // 2), audio_bytes) + rms = math.sqrt(sum(x * x for x in samples) / len(samples)) / 32768.0 + return round(min(rms * 10, 1.0), 3) + + +async def analyze_audio(audio_bytes: bytes) -> VocalState: + """Call SpeechBrain for emotion/pitch. Falls back to energy-only if unavailable.""" + energy = compute_energy(audio_bytes) + state = VocalState(energy=energy) + + try: + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(16000) + wf.writeframes(audio_bytes) + wav_bytes = buf.getvalue() + + async with httpx.AsyncClient(timeout=3.0) as client: + r = await client.post( + f"{SPEECHBRAIN_URL}/analyze", + files={"audio": ("u.wav", wav_bytes, "audio/wav")}, + ) + r.raise_for_status() + d = r.json() + state.emotion = d.get("emotion", "neutral") + state.confidence = d.get("emotion_confidence", 0.0) + state.pitch_hz = d.get("pitch_mean_hz") + state.rate = d.get("speaking_rate_approx", 0.0) + except Exception as e: + logger.debug("SpeechBrain unavailable, using energy-only: %s", e) + + return state + + +class VocalAnalyzer: + """Holds the last completed vocal state; updates async after each utterance.""" + + def __init__(self, on_update: Optional[Callable[[VocalState], None]] = None): + self._state = VocalState() + self._on_update = on_update + self._audio_buffer: list[bytes] = [] + self._collecting = False + + @property + def current(self) -> VocalState: + return self._state + + def start_collecting(self): + self._collecting = True + self._audio_buffer = [] + + def feed(self, audio_chunk: bytes): + if self._collecting: + self._audio_buffer.append(audio_chunk) + + def stop_collecting_and_analyze(self) -> asyncio.Task: + self._collecting = False + audio = b"".join(self._audio_buffer) + self._audio_buffer = [] + return asyncio.create_task(self._analyze_bg(audio)) + + async def _analyze_bg(self, audio_bytes: bytes): + if not audio_bytes: + return + try: + state = await analyze_audio(audio_bytes) + self._state = state + if self._on_update: + self._on_update(state) + except Exception as e: + logger.warning("Vocal analysis failed: %s", e) diff --git a/compose/hf-pdocker-01/voicecoach-v2/docker-compose.yml b/compose/hf-pdocker-01/voicecoach-v2/docker-compose.yml new file mode 100644 index 0000000..31c9876 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/docker-compose.yml @@ -0,0 +1,141 @@ +services: + + # livekit-agents 1.5 worker + token/API server + voicecoach-agent: + build: + context: ./agent + image: voicecoach-agent:latest + container_name: voicecoach-agent + restart: unless-stopped + env_file: .env + environment: + - LIVEKIT_URL=ws://voicecoach-livekit:7880 + - LIVEKIT_PUBLIC_URL=wss://voice.hobbs.farm + - SPEACHES_URL=http://192.168.86.27:8000 + - OPENCLAW_URL=http://voicecoach-openclaw:3100 + - DB_PATH=/data/sessions.db + - KOKORO_VOICE=af_nova + volumes: + - /mnt/docker-data/configs/voicecoach/sessions:/data + networks: + - voicecoach + - voicecoach_v1 + mem_limit: 1G + memswap_limit: 1G + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + # SvelteKit web client + voicecoach-web: + build: + context: ./web-client + image: voicecoach-web-v2:latest + container_name: voicecoach-web-v2 + restart: unless-stopped + ports: + - "8085:80" + depends_on: + - voicecoach-agent + networks: + - voicecoach + mem_limit: 128M + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # LiveKit server — unchanged from v1 + livekit-server: + image: livekit/livekit-server:latest + container_name: voicecoach-livekit + restart: unless-stopped + command: + - --config + - /etc/livekit/livekit.yaml + - --keys + - "${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}" + ports: + - "7880:7880" + - "7881:7881" + - "50000-50100:50000-50100/udp" + volumes: + - ./livekit/livekit.yaml:/etc/livekit/livekit.yaml:ro + networks: + - voicecoach + mem_limit: 512M + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + # OpenClaw — LLM gateway with memory and tools + voicecoach-openclaw: + image: margaret-openclaw:latest + container_name: voicecoach-openclaw + restart: unless-stopped + user: "0:0" + ports: + - "3100:3100" + environment: + - NODE_ENV=production + - HOME=/home/glyptic + - USER=glyptic + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OPENCLAW_STATE_DIR=/home/glyptic/.voicecoach + - OPENCLAW_CONFIG_PATH=/home/glyptic/.voicecoach/openclaw.json + volumes: + - /mnt/infrastructure/voicecoach:/home/glyptic/.voicecoach + command: ["sh", "-c", "mkdir -p /home/glyptic && node --max-old-space-size=1500 /usr/local/lib/node_modules/openclaw/dist/index.js gateway --allow-unconfigured"] + networks: + - voicecoach + mem_limit: 2G + memswap_limit: 2G + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + + # SpeechBrain — async vocal/emotion analysis (optional, agent falls back gracefully) + speechbrain-service: + build: + context: ./speechbrain-service + image: voicecoach-speechbrain:latest + container_name: voicecoach-speechbrain + restart: unless-stopped + volumes: + - /mnt/docker-data/configs/voicecoach/speechbrain-models:/app/models + environment: + - MODEL_DIR=/app/models + networks: + - voicecoach + mem_limit: 3G + memswap_limit: 3G + labels: + - "com.centurylinklabs.watchtower.enable=false" + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" + +networks: + voicecoach: + driver: bridge + # Join the v1 voicecoach network to reach the shared livekit + openclaw containers + voicecoach_v1: + external: true + name: voicecoach_voicecoach diff --git a/compose/hf-pdocker-01/voicecoach-v2/livekit/livekit.yaml b/compose/hf-pdocker-01/voicecoach-v2/livekit/livekit.yaml new file mode 100644 index 0000000..20fd6f6 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/livekit/livekit.yaml @@ -0,0 +1,15 @@ +port: 7880 +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 50100 + use_external_ip: true + +logging: + json: false + level: info + +room: + max_participants: 5 + empty_timeout: 300 + departure_timeout: 30 diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/Dockerfile b/compose/hf-pdocker-01/voicecoach-v2/web-client/Dockerfile new file mode 100644 index 0000000..9ee6872 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install --legacy-peer-deps +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/build /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/nginx.conf b/compose/hf-pdocker-01/voicecoach-v2/web-client/nginx.conf new file mode 100644 index 0000000..883d42f --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # Proxy API calls to the agent's FastAPI server + location /api/ { + proxy_pass http://voicecoach-agent:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/package.json b/compose/hf-pdocker-01/voicecoach-v2/web-client/package.json new file mode 100644 index 0000000..29957cc --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "voicecoach-v2-web", + "version": "2.0.0", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "type": "module", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^5.0.0", + "vite": "^5.0.0" + }, + "dependencies": { + "livekit-client": "^2.5.0" + } +} diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.css b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.css new file mode 100644 index 0000000..1d407a4 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.css @@ -0,0 +1,40 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #22263a; + --accent: #6c63ff; + --accent2: #4ecdc4; + --text: #e8eaf0; + --muted: #7b8094; + --danger: #ff5252; + --success: #4caf50; + --warn: #ffc107; + --radius: 12px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); } + +button { + cursor: pointer; + border: none; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + border-radius: var(--radius); + padding: 0.5rem 1.2rem; + transition: opacity 0.15s, transform 0.1s; +} +button:active { transform: scale(0.97); } +button:disabled { opacity: 0.4; cursor: default; } + +.btn-primary { background: var(--accent); color: #fff; } +.btn-primary:hover:not(:disabled) { opacity: 0.85; } +.btn-danger { background: var(--danger); color: #fff; } +.btn-ghost { background: var(--surface2); color: var(--text); } +.btn-ghost:hover:not(:disabled) { background: #2d3147; } + +a { color: var(--accent2); text-decoration: none; } +a:hover { text-decoration: underline; } diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.html b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.html new file mode 100644 index 0000000..2baac97 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/app.html @@ -0,0 +1,12 @@ + + + + + + VoiceCoach + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/lib/livekit.js b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/lib/livekit.js new file mode 100644 index 0000000..1a9134e --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/lib/livekit.js @@ -0,0 +1,66 @@ +import { Room, RoomEvent, Track } from 'livekit-client'; + +const LIVEKIT_URL = 'wss://voice.hobbs.farm'; +const API_BASE = '/api'; + +export let room = null; + +export async function connect(identity, onEvent) { + const res = await fetch(`${API_BASE}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: identity || `jeremy-${Date.now()}` }), + }); + if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`); + const { token, url } = await res.json(); + + room = new Room({ + audioCaptureDefaults: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + room.on(RoomEvent.Connected, () => onEvent({ type: 'connected' })); + room.on(RoomEvent.Disconnected, (reason) => onEvent({ type: 'disconnected', reason })); + room.on(RoomEvent.ParticipantConnected, (p) => onEvent({ type: 'participant_joined', identity: p.identity })); + room.on(RoomEvent.ParticipantDisconnected, (p) => onEvent({ type: 'participant_left', identity: p.identity })); + + room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => { + if (track.kind === Track.Kind.Audio) { + const el = track.attach(); + el.autoplay = true; + document.body.appendChild(el); + onEvent({ type: 'audio_attached', identity: participant.identity }); + } + }); + + room.on(RoomEvent.TrackUnsubscribed, (track) => { + track.detach().forEach(el => el.remove()); + }); + + room.on(RoomEvent.DataReceived, (payload, participant) => { + try { + const msg = JSON.parse(new TextDecoder().decode(payload)); + onEvent(msg); + } catch (_) {} + }); + + await room.connect(url || LIVEKIT_URL, token); + await room.localParticipant.setMicrophoneEnabled(true); + return room; +} + +export async function disconnect() { + if (room) { + await room.disconnect(); + room = null; + } +} + +export function sendData(payload) { + if (!room?.localParticipant) return; + const data = new TextEncoder().encode(JSON.stringify(payload)); + room.localParticipant.publishData(data, { reliable: true }); +} diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.js b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.js new file mode 100644 index 0000000..ae88a27 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.js @@ -0,0 +1,2 @@ +export const prerender = false; +export const ssr = false; diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.svelte b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.svelte new file mode 100644 index 0000000..454f7b8 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+layout.svelte @@ -0,0 +1,6 @@ + + +{@render children()} diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+page.svelte b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+page.svelte new file mode 100644 index 0000000..f20a9c3 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/+page.svelte @@ -0,0 +1,328 @@ + + +
+
+ + +
+ +
+ +
+ {#each MODES as m} + + {/each} +
+ + +
+ {#if transcript.length === 0 && status === 'disconnected'} +

Press Start to connect your voice.

+ {:else if transcript.length === 0 && status !== 'disconnected'} +

Listening…

+ {/if} + {#each transcript as msg (msg.id)} +
+ {msg.role === 'user' ? 'You' : 'Assistant'} + {msg.text} +
+ {/each} +
+ + + {#if tools.length > 0} +
+ {#each tools.slice(-5) as t (t.id)} +
+ {t.done ? '✓' : '⚙'} + {t.action}{t.detail ? ` — ${t.detail}` : ''} +
+ {/each} +
+ {/if} + + + {#if status === 'connected'} +
+
+
+
+ {#if vocal && vocal.emotion && vocal.emotion !== 'neutral'} + {vocal.emotion} + {/if} +
+ {/if} + + + {#if error} +

{error}

+ {/if} + + +
+ {#if status === 'disconnected'} + + {:else if status === 'connecting'} + + {:else} + + {/if} +
+
+ + + {#if vadRing} +
+ {/if} +
+ + diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/history/+page.svelte b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/history/+page.svelte new file mode 100644 index 0000000..80adca3 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/src/routes/history/+page.svelte @@ -0,0 +1,164 @@ + + +
+
+ ← Back +

Session History

+
+ + {#if loading} +

Loading…

+ {:else if sessions.length === 0} +

No sessions yet.

+ {:else} +
+ {#each sessions as s} +
+ + + {#if expanded === s.id && messages[s.id]} +
+ {#each messages[s.id] as msg} +
+ + {msg.role === 'user' ? 'You' : 'Assistant'} + {#if msg.vocal_emotion && msg.vocal_emotion !== 'neutral'} + {msg.vocal_emotion} + {/if} + + {msg.content} +
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+ + diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/svelte.config.js b/compose/hf-pdocker-01/voicecoach-v2/web-client/svelte.config.js new file mode 100644 index 0000000..a498ce9 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/svelte.config.js @@ -0,0 +1,7 @@ +import adapter from '@sveltejs/adapter-static'; + +export default { + kit: { + adapter: adapter({ fallback: 'index.html' }), + }, +}; diff --git a/compose/hf-pdocker-01/voicecoach-v2/web-client/vite.config.js b/compose/hf-pdocker-01/voicecoach-v2/web-client/vite.config.js new file mode 100644 index 0000000..c8bf808 --- /dev/null +++ b/compose/hf-pdocker-01/voicecoach-v2/web-client/vite.config.js @@ -0,0 +1,8 @@ +import { sveltekit } from '@sveltejs/kit/vite' + +/** @type {import('vite').UserConfig} */ +const config = { + plugins: [sveltekit()] +} + +export default config