3 Commits

Author SHA1 Message Date
javis-bot
b18217fcdd fix: let melo-worker honour MELO_DEVICE from env (was hardcoded cpu)
supervisord hardcoded MELO_DEVICE=cpu, overriding the compose MELO_DEVICE=cuda
so MeloTTS stayed on CPU even after the GPU torch swap. Interpolate the
container env instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 02:26:24 +09:00
javis-bot
3d1e56f60f feat: controlBrowser tool — human-operable on-screen Chrome
Adds a general browser-control tool (navigate to any site, list/open/close/
switch tabs, close popups, click, type, scroll, screenshot) for the Go-Live
Chrome, on top of the existing CDP + xdotool human-input layer (visible
cursor, char-by-char typing). Closes the gap where "open Naver" had no tool
and the model confabulated success. Also adds a system-prompt rule against
claiming actions no tool actually performed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 02:22:36 +09:00
javis-bot
927d59f805 perf: run MeloTTS on the GPU (cu128 torch) + warm CUDA at startup
CPU MeloTTS serialised under concurrent load (whisper STT + bot) and blew
voice-reply TTS to 7-8s. Install the Blackwell-verified cu128 torch in the
melo venv, select the GPU via MELO_DEVICE=cuda, and do a throwaway synth at
worker startup so the one-off CUDA kernel-init (~5s) doesn't land on the
user's first reply. Measured: ~0.3s/sentence on GPU vs ~1.2-2.6s on CPU.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-14 02:22:36 +09:00
8 changed files with 369 additions and 8 deletions

View File

@@ -0,0 +1,193 @@
// Human-operable Chrome control for the on-screen (Go-Live) browser.
//
// node chrome-control.mjs '<json-command>'
//
// Connects to the on-screen Chrome over CDP (so every action is visible on the
// broadcast) and performs ONE command, printing a JSON result on stdout for the
// Python `controlBrowser` tool to wrap. READ actions use the CDP/DOM API;
// ACTION actions (navigate/click/type/scroll) drive real input via xdotool
// (human.mjs) so the cursor visibly moves and text is typed one char at a time,
// exactly as a person would — never synthetic DOM events.
//
// Commands (json): { "action": "<name>", ...params }
// status -> broadcast/browser readiness + tab list
// listTabs -> [{index,url,title,active}]
// navigate {url, human?} -> open url in the active tab
// newTab {url?} -> open a new tab (optionally to url)
// closeTab {index} -> close tab by index
// activateTab{index} -> bring tab to front
// closePopups -> close blank/popup tabs, keep the active one
// click {selector, human?} -> click an element (human cursor by default)
// type {text, selector?, human?}-> type text char-by-char (human by default)
// scroll {dir, notches?} -> wheel scroll (dir: "down"|"up")
// pressKey {key} -> press a single key (xdotool keysym)
// screenshot {path} -> save a PNG of the active tab
import { chromium } from 'playwright';
import {
humanClick,
humanType,
pressKey,
humanScroll,
navigateOmnibox,
} from './human.mjs';
const CDP = process.env.CDP_PORT || '9222';
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
const out = (o) => process.stdout.write(JSON.stringify(o));
let cmd;
try {
cmd = JSON.parse(process.argv[2] || '{}');
} catch (e) {
out({ ok: false, error: `bad command json: ${e?.message || e}` });
process.exit(1);
}
const action = String(cmd.action || '').trim();
if (!action) {
out({ ok: false, error: 'no action' });
process.exit(1);
}
const tabInfo = async (pages, active) => {
const list = [];
for (let i = 0; i < pages.length; i++) {
const p = pages[i];
list.push({
index: i,
url: p.url(),
title: await p.title().catch(() => ''),
active: p === active,
});
}
return list;
};
let b;
try {
b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
const ctx = b.contexts()[0];
if (!ctx) throw new Error('no browser context (is Chrome running?)');
const pages = ctx.pages();
// The "active" tab: the most recently fronted page. Playwright has no direct
// accessor, so we treat the first visible page as active and let activateTab
// change focus explicitly.
let page = pages.find((p) => p.url() && p.url() !== 'about:blank') || pages[0] || (await ctx.newPage());
page.setDefaultTimeout(20000);
switch (action) {
case 'status': {
out({ ok: true, browserOpen: true, tabCount: pages.length, tabs: await tabInfo(pages, page) });
break;
}
case 'listTabs': {
out({ ok: true, tabs: await tabInfo(pages, page) });
break;
}
case 'navigate': {
const url = String(cmd.url || '').trim();
if (!url) throw new Error('navigate: no url');
const target = /^https?:\/\//i.test(url) ? url : `https://${url}`;
await page.bringToFront().catch(() => {});
if (cmd.human === false) {
await page.goto(target, { waitUntil: 'domcontentloaded' });
} else {
// Type the URL into the omnibox char-by-char with a real cursor.
await navigateOmnibox(target);
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
out({ ok: true, url: page.url(), title: await page.title().catch(() => '') });
break;
}
case 'newTab': {
const np = await ctx.newPage();
await np.bringToFront().catch(() => {});
if (cmd.url) {
const target = /^https?:\/\//i.test(cmd.url) ? cmd.url : `https://${cmd.url}`;
await np.goto(target, { waitUntil: 'domcontentloaded' }).catch(() => {});
}
out({ ok: true, index: ctx.pages().length - 1, url: np.url() });
break;
}
case 'closeTab': {
const idx = Number(cmd.index);
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('closeTab: bad index');
await pages[idx].close();
out({ ok: true, closed: idx, remaining: ctx.pages().length });
break;
}
case 'activateTab': {
const idx = Number(cmd.index);
if (!Number.isInteger(idx) || idx < 0 || idx >= pages.length) throw new Error('activateTab: bad index');
await pages[idx].bringToFront();
out({ ok: true, active: idx, url: pages[idx].url() });
break;
}
case 'closePopups': {
// Close throwaway/popup tabs (about:blank or anything that isn't the
// active content tab), keeping the active one.
let closed = 0;
for (const p of pages) {
if (p === page) continue;
const u = p.url();
if (!u || u === 'about:blank' || cmd.all) {
await p.close().catch(() => {});
closed++;
}
}
out({ ok: true, closed, remaining: ctx.pages().length });
break;
}
case 'click': {
const selector = String(cmd.selector || '').trim();
if (!selector) throw new Error('click: no selector');
await page.bringToFront().catch(() => {});
const locator = page.locator(selector).first();
if (cmd.human === false) await locator.click();
else await humanClick(page, locator);
out({ ok: true });
break;
}
case 'type': {
const text = String(cmd.text ?? '');
await page.bringToFront().catch(() => {});
if (cmd.selector) {
const locator = page.locator(String(cmd.selector)).first();
if (cmd.human === false) { await locator.fill(text); out({ ok: true }); break; }
await humanClick(page, locator); // focus the field with a real click first
}
await humanType(text);
out({ ok: true });
break;
}
case 'scroll': {
const dir = String(cmd.dir || 'down').toLowerCase() === 'up' ? -1 : 1;
await humanScroll(page, dir, Number(cmd.notches) || 5);
out({ ok: true });
break;
}
case 'pressKey': {
const key = String(cmd.key || '').trim();
if (!key) throw new Error('pressKey: no key');
await pressKey(key);
out({ ok: true });
break;
}
case 'screenshot': {
const path = String(cmd.path || '').trim();
if (!path) throw new Error('screenshot: no path');
await page.bringToFront().catch(() => {});
await page.screenshot({ path });
out({ ok: true, path });
break;
}
default:
out({ ok: false, error: `unknown action: ${action}` });
await b.close();
process.exit(1);
}
await b.close();
} catch (e) {
try { await b?.close(); } catch { /* ignore */ }
out({ ok: false, error: String(e?.message || e) });
process.exit(1);
}

View File

@@ -66,6 +66,20 @@ def _ensure_model() -> None:
speaker_id = spk_map[LANGUAGE] if LANGUAGE in spk_map else spk_map[keys[0]]
_model = model
_speaker_id = speaker_id
# Warm the GPU once at load: the first CUDA synth pays a one-off
# kernel-init cost (~5s) that would otherwise land on the user's
# first reply. A throwaway synth here moves it to startup. No-op
# cost on CPU.
try:
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as _wt:
_wp = _wt.name
model.tts_to_file("워밍업", speaker_id, _wp, speed=SPEED)
try:
os.unlink(_wp)
except OSError:
pass
except Exception as _we: # pragma: no cover
print(f"[melo-worker] warmup synth skipped: {_we}", flush=True)
print(
f"[melo-worker] ready (lang={LANGUAGE} speed={SPEED} "
f"device={DEVICE} speakers={list(spk_map.keys())})",

View File

@@ -67,6 +67,9 @@ services:
WHISPER_MODEL: ${WHISPER_MODEL:-medium}
WHISPER_DEVICE: ${WHISPER_DEVICE:-cuda}
WHISPER_COMPUTE_TYPE: ${WHISPER_COMPUTE_TYPE:-float16}
# MeloTTS on the GPU (cu128 torch baked by docker/setup-melo.sh). CPU synth
# serialised under load and pushed TTS to 7-8s; GPU does ~0.3s/sentence.
MELO_DEVICE: ${MELO_DEVICE:-cuda}
# Optional single-language lock for replies (empty = user's own language).
OUTPUT_LANGUAGE: ${OUTPUT_LANGUAGE:-}
# Drop the pre-loop planner LLM call to cut voice-reply latency on small

View File

@@ -9,8 +9,11 @@
# - It isolates the heavy torch/transformers stack from the slim bridge env,
# which pins numpy<2 for faster-whisper.
#
# torch is pinned to the CPU build: TTS runs on CPU so the GPU stays reserved
# for Ollama + Whisper, and we avoid pulling multi-GB CUDA wheels.
# torch is the CUDA (cu128) build so MeloTTS runs on the GPU alongside Ollama +
# Whisper. CPU synth serialised under concurrent load (whisper STT + bot) and
# blew TTS up to 7-8s per reply; on the GPU a sentence synthesises in ~0.3s.
# cu128 is the Blackwell (sm_120) wheel verified on this host's RTX 5050.
# The worker selects the device via MELO_DEVICE=cuda (compose).
# ============================================================================
set -euxo pipefail
@@ -29,11 +32,11 @@ rm -rf /var/lib/apt/lists/*
python3.11 -m venv /opt/melo
/opt/melo/bin/pip install --no-cache-dir --upgrade pip wheel setuptools
# CPU-only torch first, so MeloTTS's unpinned `torch` dep is already satisfied
# and pip does not pull the CUDA build. Pinned for reproducible rebuilds (these
# are the versions the CPU index resolved when this layer was verified).
/opt/melo/bin/pip install --no-cache-dir torch==2.12.0 torchaudio==2.11.0 \
--index-url https://download.pytorch.org/whl/cpu
# CUDA (cu128) torch first, so MeloTTS's unpinned `torch` dep is already
# satisfied with the GPU build. Pinned to the Blackwell-verified versions
# (2.11.0+cu128) for reproducible rebuilds.
/opt/melo/bin/pip install --no-cache-dir torch==2.11.0+cu128 torchaudio==2.11.0+cu128 \
--index-url https://download.pytorch.org/whl/cu128
# MeloTTS from GitHub. The PyPI sdist is broken (its setup.py reads a
# requirements.txt that is not shipped in the sdist), so install from the repo.

View File

@@ -61,7 +61,10 @@ directory=/app
; HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE force pure-cache reads: the pinned old
; transformers/huggingface_hub otherwise retry the network on every load and
; error out instead of falling back to the (complete) baked cache.
environment=MELO_LANGUAGE="KR",MELO_SPEED="1.5",MELO_DEVICE="cpu",MELO_WORKER_HOST="127.0.0.1",MELO_WORKER_PORT="8770",HF_HOME="/opt/melo-cache",HF_HUB_OFFLINE="1",TRANSFORMERS_OFFLINE="1"
; MELO_DEVICE inherits from the container env (compose sets it; default cuda)
; so the worker runs MeloTTS on the GPU. supervisord interpolates %(ENV_x)s
; from its own environment, which is the container's.
environment=MELO_LANGUAGE="KR",MELO_SPEED="1.5",MELO_DEVICE="%(ENV_MELO_DEVICE)s",MELO_WORKER_HOST="127.0.0.1",MELO_WORKER_PORT="8770",HF_HOME="/opt/melo-cache",HF_HUB_OFFLINE="1",TRANSFORMERS_OFFLINE="1"
priority=280
autorestart=true
stdout_logfile=/dev/stdout

View File

@@ -36,6 +36,10 @@ _SYSTEM_PROMPT_TEMPLATE: str = (
"butler clichés, and never address the user as 'sir', 'madam', 'my liege', or similar. "
"Never stack multiple jokes in one reply. "
"Be concise, conversational, and actionable. "
"Never claim you performed an action — opening a website, navigating the browser, "
"playing or showing something on screen, changing a setting, sending a message — unless a "
"tool actually did it this turn and reported success. If you have no tool for what was asked, "
"or the tool failed, say so plainly; do not narrate a success that did not happen. "
"Never answer with a bare greeting like 'Hey there!', 'Hi!', 'Hello, how can I help you?', "
"'I hope you have a relaxing time today', or 'I'm here and ready to chat'. Always engage "
"with the user's actual prompt, and when the 'Information the user has shared…' section is "

View File

@@ -0,0 +1,139 @@
"""Operate the on-screen (Go-Live) Chrome like a person would.
Only meaningful in screen-share mode (``STREAM_BROWSER`` true): it drives the
on-screen Chrome via the Node CDP helper (``chrome-control.mjs``) so every
action is visible on the broadcast. Pointer moves, clicks and typing are real
xdotool input (visible cursor, char-by-char typing), never synthetic DOM
events.
This is the general browser-control tool (navigate to any site, manage
windows/tabs, click, type, scroll, screenshot). ``browseAndPlay`` remains the
YouTube-playback shortcut.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Dict, Any, Optional
from ..base import Tool, ToolContext
from ..types import ToolExecutionResult
from ...debug import debug_log
# .../owner/src/jarvis/tools/builtin/control_browser.py -> parents[4] == .../owner
_REPO_ROOT = Path(__file__).resolve().parents[4]
_NODE_SCRIPT = _REPO_ROOT / "bot" / "scripts" / "stream-test" / "chrome-control.mjs"
# Actions that don't change anything (safe, fast, no human-input latency).
_READ_ACTIONS = {"status", "listTabs"}
class ControlBrowserTool(Tool):
"""Drive the on-screen Chrome: navigate, manage tabs, click, type, scroll."""
@property
def name(self) -> str:
return "controlBrowser"
@property
def description(self) -> str:
return (
"Operate the on-screen Chrome shown on the broadcast like a person: open a "
"website/URL, list/open/close/switch tabs, close popups, click an element, "
"type text, scroll, or screenshot. Use whenever the user asks to go to a site "
"(e.g. 'open Naver'), check what's on screen, or interact with the page. "
"Only available in screen-share mode. Do NOT claim you did any of this unless "
"this tool returns success."
)
@property
def inputSchema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"status", "listTabs", "navigate", "newTab", "closeTab",
"activateTab", "closePopups", "click", "type", "scroll",
"pressKey", "screenshot",
],
"description": "What to do in the browser.",
},
"url": {"type": "string", "description": "Target URL/site for navigate/newTab (e.g. 'naver.com')."},
"index": {"type": "integer", "description": "Tab index for closeTab/activateTab (from listTabs)."},
"selector": {"type": "string", "description": "CSS selector for click/type."},
"text": {"type": "string", "description": "Text to type."},
"key": {"type": "string", "description": "Key to press, e.g. 'Return', 'Escape'."},
"dir": {"type": "string", "description": "Scroll direction: 'down' or 'up'."},
},
"required": ["action"],
}
def run(self, args: Optional[Dict[str, Any]], context: ToolContext) -> ToolExecutionResult:
cfg = context.cfg
if not getattr(cfg, "stream_browser", True):
return ToolExecutionResult(
success=False,
reply_text="화면 공유 모드(STREAM_BROWSER=true)에서만 브라우저를 제어할 수 있습니다.",
)
if not _NODE_SCRIPT.exists():
return ToolExecutionResult(success=False, reply_text="브라우저 제어 도구를 찾을 수 없습니다.")
if not args or not isinstance(args, dict):
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
action = str(args.get("action", "")).strip()
if not action:
return ToolExecutionResult(success=False, reply_text="수행할 동작(action)을 알려주세요.")
# Pass the whole arg dict through as the command (the Node side reads the
# fields it needs per action).
command = json.dumps(args, ensure_ascii=False)
context.user_print(f"🖱️ 브라우저 제어: {action}")
debug_log(f" 🖱️ controlBrowser {command[:120]}", "tools")
# Human-input actions need time for the visible cursor move + char typing.
timeout = 25 if action in _READ_ACTIONS else 90
try:
proc = subprocess.run(
["node", str(_NODE_SCRIPT), command],
capture_output=True,
text=True,
timeout=timeout,
env={**os.environ, "CDP_PORT": os.environ.get("CDP_PORT", "9222")},
)
data = json.loads((proc.stdout or "").strip() or "{}")
except Exception as e:
return ToolExecutionResult(success=False, reply_text=f"브라우저 제어에 실패했습니다: {e}")
if not data.get("ok"):
return ToolExecutionResult(
success=False, reply_text=f"브라우저 제어에 실패했습니다: {data.get('error', 'unknown')}"
)
# Return a factual summary of what ACTUALLY happened so the reply engine
# describes the real outcome and doesn't confabulate.
summary = self._summarise(action, args, data)
return ToolExecutionResult(success=True, reply_text=summary)
@staticmethod
def _summarise(action: str, args: Dict[str, Any], data: Dict[str, Any]) -> str:
if action == "navigate":
return f"브라우저에서 {data.get('url', args.get('url'))} 로 이동했습니다."
if action in ("status", "listTabs"):
tabs = data.get("tabs", [])
lines = "\n".join(f" [{t['index']}] {t.get('title') or t['url']}" for t in tabs) or " (탭 없음)"
return f"브라우저 탭 {len(tabs)}개:\n{lines}"
if action == "newTab":
return f"새 탭을 열었습니다 (index {data.get('index')})."
if action == "closeTab":
return f"{data.get('closed')}번을 닫았습니다 (남은 탭 {data.get('remaining')}개)."
if action == "activateTab":
return f"{data.get('active')}번으로 전환했습니다."
if action == "closePopups":
return f"팝업/빈 탭 {data.get('closed')}개를 닫았습니다."
if action == "screenshot":
return f"화면을 캡처했습니다: {data.get('path')}"
return "완료했습니다."

View File

@@ -21,6 +21,7 @@ from .builtin.weather import WeatherTool
from .builtin.stop import StopTool
from .builtin.tool_search import ToolSearchTool
from .builtin.browse_and_play import BrowseAndPlayTool
from .builtin.control_browser import ControlBrowserTool
from .builtin.set_broadcast import SetBroadcastTool
from .types import ToolExecutionResult
from ..config import Settings
@@ -42,6 +43,7 @@ BUILTIN_TOOLS = {
"stop": StopTool(),
"toolSearchTool": ToolSearchTool(),
"browseAndPlay": BrowseAndPlayTool(),
"controlBrowser": ControlBrowserTool(),
"setBroadcast": SetBroadcastTool(),
}