diff --git a/Dockerfile b/Dockerfile index 8d09629..59001ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ DISPLAY=:1 \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ PATH=/opt/venv/bin:/root/.bun/bin:/usr/local/bin:/usr/bin:/bin \ NVIDIA_VISIBLE_DEVICES=all \ NVIDIA_DRIVER_CAPABILITIES=compute,utility diff --git a/bot/bun.lock b/bot/bun.lock index a7ef534..7705ed7 100644 --- a/bot/bun.lock +++ b/bot/bun.lock @@ -10,6 +10,7 @@ "dotenv": "^16.4.5", "libsodium-wrappers": "^0.7.15", "opusscript": "^0.1.1", + "playwright": "^1.60.0", "prism-media": "^1.3.5", "qrcode": "^1.5.4", }, @@ -318,6 +319,8 @@ "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -408,6 +411,10 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], + + "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], diff --git a/bot/package.json b/bot/package.json index 91f22fe..44b00c8 100644 --- a/bot/package.json +++ b/bot/package.json @@ -16,6 +16,7 @@ "dotenv": "^16.4.5", "libsodium-wrappers": "^0.7.15", "opusscript": "^0.1.1", + "playwright": "^1.60.0", "prism-media": "^1.3.5", "qrcode": "^1.5.4" }, diff --git a/bot/scripts/stream-test/browse-search.mjs b/bot/scripts/stream-test/browse-search.mjs index 5819f2b..7f0a205 100644 --- a/bot/scripts/stream-test/browse-search.mjs +++ b/bot/scripts/stream-test/browse-search.mjs @@ -9,6 +9,9 @@ import { chromium } from 'playwright'; const CDP = process.env.CDP_PORT || '9222'; +// Use 127.0.0.1, not "localhost": in containers localhost can resolve to IPv6 +// (::1) first while Chrome's CDP listens on IPv4, giving ECONNREFUSED ::1. +const CDP_HOST = process.env.CDP_HOST || '127.0.0.1'; const query = process.argv[2] || ''; const mode = (process.argv[3] || 'search').toLowerCase(); const out = (o) => { process.stdout.write(JSON.stringify(o)); }; @@ -17,7 +20,7 @@ if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); } let b; try { - b = await chromium.connectOverCDP(`http://localhost:${CDP}`); + b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`); const ctx = b.contexts()[0]; const page = ctx.pages()[0] || (await ctx.newPage()); page.setDefaultTimeout(20000); diff --git a/bot/src/index.ts b/bot/src/index.ts index 6367170..056322b 100644 --- a/bot/src/index.ts +++ b/bot/src/index.ts @@ -110,6 +110,16 @@ async function handleJoin(i: ChatInputCommandInteraction) { async function handleLeave(i: ChatInputCommandInteraction) { const left = leaveGuild(i.guildId!); + // Leaving voice also ends the broadcast — don't leave a stream live with no + // session driving it. + const streamer = streamers.get(i.guildId!); + if (streamer?.isActive()) { + try { + await streamer.stop(); + } catch (e) { + console.error("[leave] stopping broadcast failed:", e); + } + } await i.reply({ content: left ? "음성 채널에서 나갔습니다." : "접속 중인 세션이 없습니다.", ...eph }); } diff --git a/docker-compose.yml b/docker-compose.yml index 70e582e..e900f6b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,14 +68,17 @@ services: - "nvidia.com/gpu=all" shm_size: "1gb" # Chrome needs a larger /dev/shm ports: - # Host ports are overridable. If the HOST already runs VNC on 5901 - # (see docs/vnc-xfce-setup.md), set VNC_PORT=5902 in .env — this server - # does, so .env pins VNC_PORT=5902. - - "${VNC_PORT:-5901}:5901" # VNC - - "${NOVNC_PORT:-6080}:6080" # noVNC (open in a browser) - # Brain bridge: bind to loopback only — it is an internal bot<->brain API - # with no auth and must not be reachable off-host. - - "127.0.0.1:${BRIDGE_PORT:-8765}:8765" + # All published to loopback only by default — VNC/noVNC use a weak default + # password and the bridge is an unauthenticated internal API, so none + # should be reachable off-host. For remote access use an SSH tunnel, or + # set a strong VNC_PASSWORD and override the bind (e.g. VNC_BIND=0.0.0.0). + # Host VNC port is overridable; this server already runs Xvnc on 5901 so + # .env pins VNC_PORT=5902. + - "${VNC_BIND:-127.0.0.1}:${VNC_PORT:-5901}:5901" # VNC + - "${VNC_BIND:-127.0.0.1}:${NOVNC_PORT:-6080}:6080" # noVNC (browser) + # The brain bridge is NOT published: it binds the container's loopback + # (BRIDGE_HOST=127.0.0.1) and is only consumed by the bot in this same + # container, so it needs no host port and stays unreachable off-container. volumes: - javis_data:/data # jarvis db + memory - whisper_cache:/root/.cache/huggingface # cached Whisper models diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index acc6ce3..c347c29 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -14,7 +14,7 @@ set -euo pipefail : "${WHISPER_DEVICE:=cuda}" : "${WHISPER_COMPUTE_TYPE:=float16}" : "${JARVIS_DB_PATH:=/data/jarvis.db}" -: "${BRIDGE_HOST:=0.0.0.0}" +: "${BRIDGE_HOST:=127.0.0.1}" : "${BRIDGE_PORT:=8765}" : "${PIPER_VOICE:=en_GB-alan-medium}" : "${PIPER_VOICE_DIR:=/opt/piper-voices}" diff --git a/docker/run-chrome.sh b/docker/run-chrome.sh index c0f578b..d41832b 100755 --- a/docker/run-chrome.sh +++ b/docker/run-chrome.sh @@ -8,7 +8,13 @@ for i in $(seq 1 40); do done sleep 3 export DISPLAY=:1 +# --remote-debugging-port exposes CDP so the brain's browse-search.mjs +# (playwright connectOverCDP) can drive this on-screen Chrome for the +# broadcast-visible Google/YouTube search. Bound to loopback (same container). exec google-chrome \ --no-sandbox --no-first-run --disable-dev-shm-usage \ + --remote-debugging-port="${CDP_PORT:-9222}" \ + --remote-debugging-address=127.0.0.1 \ + --user-data-dir="${CHROME_PROFILE_DIR:-/root/chrome-profile}" \ --password-store=basic --start-maximized \ "${CHROME_START_URL:-about:blank}" diff --git a/src/jarvis/reply/engine.py b/src/jarvis/reply/engine.py index fdc9503..76e892e 100644 --- a/src/jarvis/reply/engine.py +++ b/src/jarvis/reply/engine.py @@ -975,6 +975,15 @@ def run_reply_engine(db: "Database", cfg, tts: Optional[Any], "planning", ) + # In screen-share mode, always offer setBroadcast. "Turn the broadcast + # on/off" is language-agnostic intent the embedding/keyword router won't + # reliably surface for a non-English utterance (e.g. "방송 꺼줘"), so the + # model would otherwise never get the option. It self-gates and is only + # acted on when the model actually calls it. + if getattr(cfg, "stream_browser", True) and "setBroadcast" in _full_catalog_names \ + and "setBroadcast" not in routed_tools: + routed_tools = routed_tools + ["setBroadcast"] + _planner_schema = generate_tools_json_schema(routed_tools, mcp_tools) _planner_tool_catalog: list[tuple[str, str]] = [] for _schema in (_planner_schema or []): diff --git a/src/jarvis/tools/builtin/set_broadcast.py b/src/jarvis/tools/builtin/set_broadcast.py index ea66677..3c22f84 100644 --- a/src/jarvis/tools/builtin/set_broadcast.py +++ b/src/jarvis/tools/builtin/set_broadcast.py @@ -26,10 +26,12 @@ class SetBroadcastTool(Tool): @property def description(self) -> str: return ( - "Start or stop the live screen-share broadcast (Go-Live / showing your " - "screen on the call). Use when the user asks to turn the broadcast or " - "screen on or off (show/hide your screen, go live, stop streaming). " - "Only available in screen-share mode." + "Control the live screen-share broadcast (Go-Live). You MUST call this " + "tool — do not just reply in words — whenever the user asks to turn the " + "broadcast or screen ON or OFF: start/begin/turn on/show your screen/go " + "live -> action='start'; stop/end/turn off/hide your screen/stop " + "streaming -> action='stop'. Saying you did it without calling this tool " + "does nothing. Only available in screen-share mode." ) @property