friend-assistant 0.1.41 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BUILD.json +2 -2
- package/dist/chrome-control/dist/index.js +255 -76
- package/dist/friend.mjs +104 -5
- package/dist/voice/friend_voice/__main__.py +7 -1
- package/dist/voice/friend_voice/models.py +6 -0
- package/dist/voice/friend_voice/server.py +38 -3
- package/dist/web/assets/index-CXlA46SS.js +4456 -0
- package/dist/web/assets/index-DrNs6WlQ.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CRWuir4x.css +0 -1
- package/dist/web/assets/index-pAv0XdRo.js +0 -4456
package/dist/friend.mjs
CHANGED
|
@@ -159,6 +159,14 @@ function resolveVoice(input) {
|
|
|
159
159
|
if (PLAIN.test(t) && PIPER_NAME.test(t)) return { id: t, label: t, listed: false };
|
|
160
160
|
return null;
|
|
161
161
|
}
|
|
162
|
+
var LISTENING_MODELS = [
|
|
163
|
+
{ id: "tiny", name: "Tiny", mb: 75, note: "fastest, mishears a lot" },
|
|
164
|
+
{ id: "base", name: "Base", mb: 145, note: "fast, struggles with Hindi, Marathi and distance" },
|
|
165
|
+
{ id: "small", name: "Small", mb: 480, note: "recommended: much better, still quick" },
|
|
166
|
+
{ id: "medium", name: "Medium", mb: 1500, note: "better again, slow on a PC without a graphics card" },
|
|
167
|
+
{ id: "large-v3-turbo", name: "Large (turbo)", mb: 1600, note: "most accurate for Marathi; needs a fast PC" }
|
|
168
|
+
];
|
|
169
|
+
var downloadSize = (mb) => `about ${mb >= 1e3 ? `${(mb / 1e3).toFixed(1)} GB` : `${mb} MB`}`;
|
|
162
170
|
|
|
163
171
|
// ../protocol/src/themes.ts
|
|
164
172
|
var THEMES = [
|
|
@@ -4792,6 +4800,53 @@ var ChromePlugin = class {
|
|
|
4792
4800
|
});
|
|
4793
4801
|
return this.serving;
|
|
4794
4802
|
}
|
|
4803
|
+
/** Runs one of Chrome control's own commands that answer with a line of JSON. */
|
|
4804
|
+
async ask(command) {
|
|
4805
|
+
const entry = this.deps.bundled;
|
|
4806
|
+
if (!entry) return null;
|
|
4807
|
+
const r = await this.deps.run(process.execPath, [entry, command]);
|
|
4808
|
+
const line = r.stdout.split(/\r?\n/).reverse().find((l) => l.trim().startsWith("{"));
|
|
4809
|
+
if (!line) return null;
|
|
4810
|
+
try {
|
|
4811
|
+
return JSON.parse(line);
|
|
4812
|
+
} catch {
|
|
4813
|
+
return null;
|
|
4814
|
+
}
|
|
4815
|
+
}
|
|
4816
|
+
/** Whether the open Chrome has the flag that hides the banner, and how many shortcuts do. Changes nothing. */
|
|
4817
|
+
async bannerStatus() {
|
|
4818
|
+
if (!this.deps.bundled) return { supported: false };
|
|
4819
|
+
const s = await this.ask("flag-status").catch(() => null);
|
|
4820
|
+
if (!s || typeof s.running !== "boolean") return { supported: true, error: "Could not read Chrome's state." };
|
|
4821
|
+
const sc = s.shortcuts;
|
|
4822
|
+
return {
|
|
4823
|
+
supported: true,
|
|
4824
|
+
platform: String(s.platform ?? ""),
|
|
4825
|
+
running: s.running,
|
|
4826
|
+
hidden: typeof s.hidden === "boolean" ? s.hidden : null,
|
|
4827
|
+
shortcuts: sc && typeof sc.total === "number" ? { withFlag: sc.withFlag ?? 0, total: sc.total } : null
|
|
4828
|
+
};
|
|
4829
|
+
}
|
|
4830
|
+
/** Adds the flag to the Chrome shortcuts. It takes effect the next time Chrome starts. */
|
|
4831
|
+
async hideBanner() {
|
|
4832
|
+
if (!this.deps.bundled) return { ok: false, message: "This Friend has no copy of Chrome control to do that." };
|
|
4833
|
+
const r = await this.ask("flag").catch(() => null);
|
|
4834
|
+
const c = r?.counts;
|
|
4835
|
+
if (!r || !c) return { ok: false, message: "Could not change the shortcuts." };
|
|
4836
|
+
const patched = c.patched ?? 0;
|
|
4837
|
+
const failed = c.failed ?? 0;
|
|
4838
|
+
const already = c.already ?? 0;
|
|
4839
|
+
if (r.ok !== true) return { ok: false, patched, failed, message: failed ? "Windows would not let Friend change those shortcuts (shared ones may need an administrator)." : "No Chrome shortcut was found to change." };
|
|
4840
|
+
const bits = [patched ? `Added the flag to ${patched} shortcut${patched === 1 ? "" : "s"}.` : "", already ? `${already} already had it.` : "", failed ? `${failed} could not be changed (shared ones may need an administrator).` : ""].filter(Boolean);
|
|
4841
|
+
return { ok: true, patched, failed, message: `${bits.join(" ")} It counts when Chrome starts: if Chrome is open now, restart it.` };
|
|
4842
|
+
}
|
|
4843
|
+
/** Closes Chrome politely and starts it again with the flag and the tabs back. */
|
|
4844
|
+
async restartChrome() {
|
|
4845
|
+
if (!this.deps.bundled) return { ok: false, message: "This Friend has no copy of Chrome control to do that." };
|
|
4846
|
+
const r = await this.ask("chrome-restart").catch(() => null);
|
|
4847
|
+
if (!r || typeof r.ok !== "boolean") return { ok: false, message: "Could not restart Chrome." };
|
|
4848
|
+
return { ok: r.ok, message: String(r.message ?? (r.ok ? "Chrome was restarted." : "Chrome was not restarted.")) };
|
|
4849
|
+
}
|
|
4795
4850
|
/** Switches the connector off but keeps it listed. */
|
|
4796
4851
|
async disconnect() {
|
|
4797
4852
|
if (!this.deps.hub.list().some((c) => c.id === ID)) return;
|
|
@@ -11537,6 +11592,18 @@ function createGateway(deps) {
|
|
|
11537
11592
|
return pluginError(reply, e, "chrome.remote");
|
|
11538
11593
|
}
|
|
11539
11594
|
});
|
|
11595
|
+
app.get("/api/plugins/chrome/banner", { onRequest: pluginGuard }, async () => chrome.banner());
|
|
11596
|
+
app.post("/api/plugins/chrome/banner/:what", { onRequest: pluginGuard }, async (req, reply) => {
|
|
11597
|
+
const what = z10.enum(["hide", "restart"]).safeParse(req.params.what);
|
|
11598
|
+
const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
|
|
11599
|
+
if (!what.success) return reply.code(404).send({ error: "Not found" });
|
|
11600
|
+
if (!body.success) return reply.code(400).send({ error: "Enter your PIN" });
|
|
11601
|
+
try {
|
|
11602
|
+
return await chrome.bannerAct(what.data, body.data.pin, req.ip);
|
|
11603
|
+
} catch (e) {
|
|
11604
|
+
return pluginError(reply, e, `chrome.banner_${what.data}`);
|
|
11605
|
+
}
|
|
11606
|
+
});
|
|
11540
11607
|
app.post("/api/plugins/chrome/token", { onRequest: pluginGuard }, async (req, reply) => {
|
|
11541
11608
|
const body = z10.object({ pin: z10.string().min(1).max(32) }).safeParse(req.body);
|
|
11542
11609
|
if (!body.success) return reply.code(400).send({ error: "Enter your PIN" });
|
|
@@ -12970,7 +13037,7 @@ function refuseForAgent(raw) {
|
|
|
12970
13037
|
if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
|
|
12971
13038
|
}
|
|
12972
13039
|
var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
|
|
12973
|
-
var RESTART_VOICE = ["enabled"
|
|
13040
|
+
var RESTART_VOICE = ["enabled"];
|
|
12974
13041
|
function applySettings(config, raw) {
|
|
12975
13042
|
const parsed = SettingsPatch.safeParse(raw);
|
|
12976
13043
|
if (!parsed.success) {
|
|
@@ -13852,7 +13919,11 @@ var HttpVoiceService = class {
|
|
|
13852
13919
|
});
|
|
13853
13920
|
return Buffer.from(await res.arrayBuffer());
|
|
13854
13921
|
}
|
|
13855
|
-
|
|
13922
|
+
/** Loading a model the first time downloads it (up to about 1.6 GB), so this waits far longer than a normal request. */
|
|
13923
|
+
async useListeningModel(name) {
|
|
13924
|
+
await this.request("/stt-model", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: name }) }, 30 * 6e4);
|
|
13925
|
+
}
|
|
13926
|
+
async request(path3, init, timeoutMs) {
|
|
13856
13927
|
const base = this.opts.baseUrl();
|
|
13857
13928
|
if (!base) throw new VoiceError("the voice sidecar is not running");
|
|
13858
13929
|
let res;
|
|
@@ -13860,7 +13931,7 @@ var HttpVoiceService = class {
|
|
|
13860
13931
|
res = await fetch(base + path3, {
|
|
13861
13932
|
...init,
|
|
13862
13933
|
headers: { ...init.headers, authorization: `Bearer ${this.opts.token}` },
|
|
13863
|
-
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 6e4)
|
|
13934
|
+
signal: AbortSignal.timeout(timeoutMs ?? this.opts.timeoutMs ?? 6e4)
|
|
13864
13935
|
});
|
|
13865
13936
|
} catch (e) {
|
|
13866
13937
|
if (e.name === "TimeoutError") throw new VoiceError("the voice sidecar timed out");
|
|
@@ -14379,6 +14450,7 @@ async function createApp(opts) {
|
|
|
14379
14450
|
function commitSettings(patch, how) {
|
|
14380
14451
|
let applied;
|
|
14381
14452
|
let voiceBefore = config.voice.ttsVoice;
|
|
14453
|
+
let sttBefore = config.voice.sttModel;
|
|
14382
14454
|
try {
|
|
14383
14455
|
const saved = loadConfig(opts.dataDir);
|
|
14384
14456
|
const check3 = applySettings(saved, patch);
|
|
@@ -14386,6 +14458,7 @@ async function createApp(opts) {
|
|
|
14386
14458
|
throw new HttpError(409, "Friend is busy with a request. Change the model when it is done.");
|
|
14387
14459
|
}
|
|
14388
14460
|
voiceBefore = config.voice.ttsVoice;
|
|
14461
|
+
sttBefore = config.voice.sttModel;
|
|
14389
14462
|
applied = applySettings(config, patch);
|
|
14390
14463
|
saveConfig(saved);
|
|
14391
14464
|
} catch (e) {
|
|
@@ -14411,6 +14484,7 @@ async function createApp(opts) {
|
|
|
14411
14484
|
else swap();
|
|
14412
14485
|
}
|
|
14413
14486
|
if (applied.changed.includes("voice.ttsVoice")) void warmVoice(config.voice.ttsVoice, voiceBefore);
|
|
14487
|
+
if (applied.changed.includes("voice.sttModel")) void warmListening(config.voice.sttModel, sttBefore);
|
|
14414
14488
|
if (applied.changed.includes("voice.hindiVoice")) void warmLanguageVoice(config.voice.hindiVoice);
|
|
14415
14489
|
if (applied.changed.includes("voice.marathiVoice")) void warmLanguageVoice(config.voice.marathiVoice);
|
|
14416
14490
|
if (applied.changed.some((k) => k === "view" || k === "ownerName" || k.startsWith("look.") || k.startsWith("hud") || k.startsWith("music"))) {
|
|
@@ -14437,6 +14511,25 @@ async function createApp(opts) {
|
|
|
14437
14511
|
broadcast({ type: "alert", level: "warn", text: `Could not load the voice ${wanted} (it needs internet the first time). Using ${voiceLabel(previous)} again.` });
|
|
14438
14512
|
}
|
|
14439
14513
|
}
|
|
14514
|
+
async function warmListening(wanted, previous) {
|
|
14515
|
+
if (!voice2?.useListeningModel || !voiceAvailable()) return;
|
|
14516
|
+
const known = LISTENING_MODELS.find((m) => m.id === wanted);
|
|
14517
|
+
const download = known ? ` (${downloadSize(known.mb)} the first time it is downloaded)` : ". The first time it downloads";
|
|
14518
|
+
broadcast({ type: "alert", level: "info", text: `Getting the listening model ${wanted}${download}. Friend keeps listening with the old one meanwhile.` });
|
|
14519
|
+
try {
|
|
14520
|
+
await voice2.useListeningModel(wanted);
|
|
14521
|
+
broadcast({ type: "alert", level: "info", text: `The listening model ${wanted} is ready.` });
|
|
14522
|
+
} catch (e) {
|
|
14523
|
+
audit.log("system", "voice.model_failed", { model: wanted, message: e.message });
|
|
14524
|
+
if (config.voice.sttModel === wanted) {
|
|
14525
|
+
config.voice.sttModel = previous;
|
|
14526
|
+
const saved = loadConfig(opts.dataDir);
|
|
14527
|
+
saved.voice.sttModel = previous;
|
|
14528
|
+
saveConfig(saved);
|
|
14529
|
+
}
|
|
14530
|
+
broadcast({ type: "alert", level: "warn", text: `Could not load the listening model ${wanted} (it needs internet the first time). Using ${previous} again.` });
|
|
14531
|
+
}
|
|
14532
|
+
}
|
|
14440
14533
|
async function warmLanguageVoice(wanted) {
|
|
14441
14534
|
if (!wanted || !voice2 || !voiceAvailable()) return;
|
|
14442
14535
|
broadcast({ type: "alert", level: "info", text: `Loading the voice ${voiceLabel(wanted)}. The first time it downloads about 64 MB.` });
|
|
@@ -14818,6 +14911,12 @@ async function createApp(opts) {
|
|
|
14818
14911
|
if (enabled) void chromeRemoteApi.ensureServer().catch(() => {
|
|
14819
14912
|
});
|
|
14820
14913
|
},
|
|
14914
|
+
banner: () => chromePlugin.bannerStatus(),
|
|
14915
|
+
bannerAct: async (what, pin, ip) => {
|
|
14916
|
+
await requirePin(pin, ip);
|
|
14917
|
+
audit.log("owner", what === "hide" ? "chrome.banner_hide" : "chrome.banner_restart", { ip });
|
|
14918
|
+
return what === "hide" ? chromePlugin.hideBanner() : chromePlugin.restartChrome();
|
|
14919
|
+
},
|
|
14821
14920
|
revealToken: async (pin, ip) => {
|
|
14822
14921
|
await requirePin(pin, ip);
|
|
14823
14922
|
const key = chromeToken(opts.quick?.home);
|
|
@@ -17844,7 +17943,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
|
|
|
17844
17943
|
import crossSpawn5 from "cross-spawn";
|
|
17845
17944
|
var PACKAGE = "friend-assistant";
|
|
17846
17945
|
var real = {
|
|
17847
|
-
current: () => true ? "0.1.
|
|
17946
|
+
current: () => true ? "0.1.43" : "development",
|
|
17848
17947
|
latest: () => new Promise((resolve13) => {
|
|
17849
17948
|
const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
17850
17949
|
let out = "";
|
|
@@ -18132,7 +18231,7 @@ async function run(argv, io2) {
|
|
|
18132
18231
|
case "version":
|
|
18133
18232
|
case "--version":
|
|
18134
18233
|
case "-v":
|
|
18135
|
-
io2.out(true ? "0.1.
|
|
18234
|
+
io2.out(true ? "0.1.43" : "development");
|
|
18136
18235
|
return 0;
|
|
18137
18236
|
case "lan":
|
|
18138
18237
|
return await lanCommand(rest, io2);
|
|
@@ -32,7 +32,13 @@ def main() -> int:
|
|
|
32
32
|
|
|
33
33
|
engines = load_engines(args.data_dir, args.stt_model, args.tts_voice)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
if args.fake:
|
|
36
|
+
loader = lambda name: FakeEngines().stt # noqa: E731
|
|
37
|
+
else:
|
|
38
|
+
from .models import load_stt
|
|
39
|
+
|
|
40
|
+
loader = lambda name: load_stt(args.data_dir, name) # noqa: E731
|
|
41
|
+
server, port = make_server(engines, token, port=args.port, load_stt=loader)
|
|
36
42
|
print(json.dumps({"ready": True, "port": port}), flush=True)
|
|
37
43
|
|
|
38
44
|
# When the parent process goes away its end of our stdin closes: shut down instead of lingering.
|
|
@@ -46,6 +46,12 @@ def ensure_speaker_model(directory: Path, url: str = SPEAKER_URL, sha256: str =
|
|
|
46
46
|
return target
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def load_stt(data_dir: Optional[str], name: str) -> WhisperSTT:
|
|
50
|
+
"""One listening model, downloaded into the models folder when it is not there yet."""
|
|
51
|
+
root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
|
|
52
|
+
return WhisperSTT(name, str(root / "whisper"))
|
|
53
|
+
|
|
54
|
+
|
|
49
55
|
def load_engines(data_dir: Optional[str], stt_model: str = "small", tts_voice: str = "en_US-lessac-medium") -> Engines:
|
|
50
56
|
root = Path(data_dir) if data_dir else Path.home() / ".friend" / "voice" / "models"
|
|
51
57
|
root.mkdir(parents=True, exist_ok=True)
|
|
@@ -4,20 +4,33 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import hmac
|
|
6
6
|
import json
|
|
7
|
+
import re
|
|
8
|
+
import threading
|
|
7
9
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
-
from typing import Tuple
|
|
10
|
+
from typing import Callable, Optional, Tuple
|
|
9
11
|
from urllib.parse import parse_qs, urlparse
|
|
10
12
|
|
|
11
13
|
from .engines import Engines
|
|
12
14
|
from .tts import check_voice_name
|
|
13
15
|
|
|
14
16
|
MAX_TTS_CHARS = 2000
|
|
17
|
+
#: What a listening model is called: tiny, base, small, large-v3-turbo, or a Hugging Face folder such as Systran/faster-whisper-small.
|
|
18
|
+
MODEL_NAME = re.compile(r"^[A-Za-z0-9_./-]{1,80}$")
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def make_server(
|
|
18
|
-
engines: Engines,
|
|
22
|
+
engines: Engines,
|
|
23
|
+
token: str,
|
|
24
|
+
port: int = 0,
|
|
25
|
+
max_body: int = 4_000_000,
|
|
26
|
+
io_timeout: float = 30,
|
|
27
|
+
load_stt: Optional[Callable[[str], object]] = None,
|
|
19
28
|
) -> Tuple[ThreadingHTTPServer, int]:
|
|
20
|
-
"""Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port).
|
|
29
|
+
"""Create (but do not start) a server bound to 127.0.0.1. Returns (server, actual_port).
|
|
30
|
+
|
|
31
|
+
`load_stt(name)` builds (and, the first time, downloads) a listening model; POST /stt-model swaps it in while running.
|
|
32
|
+
"""
|
|
33
|
+
swapping = threading.Lock() # one model change at a time
|
|
21
34
|
|
|
22
35
|
class Handler(BaseHTTPRequestHandler):
|
|
23
36
|
protocol_version = "HTTP/1.1"
|
|
@@ -85,6 +98,8 @@ def make_server(
|
|
|
85
98
|
return self._tts()
|
|
86
99
|
if url.path == "/embed":
|
|
87
100
|
return self._embed()
|
|
101
|
+
if url.path == "/stt-model":
|
|
102
|
+
return self._stt_model()
|
|
88
103
|
self._drain(64 * 1024)
|
|
89
104
|
self._json(404, {"error": "not found"}, close=True)
|
|
90
105
|
|
|
@@ -118,6 +133,26 @@ def make_server(
|
|
|
118
133
|
except Exception as e: # engine failures are reported, never crash the server
|
|
119
134
|
self._json(500, {"error": str(e)})
|
|
120
135
|
|
|
136
|
+
def _stt_model(self) -> None:
|
|
137
|
+
raw = self._read_body()
|
|
138
|
+
if raw is None:
|
|
139
|
+
return
|
|
140
|
+
if load_stt is None:
|
|
141
|
+
return self._json(501, {"error": "this speech engine cannot change its listening model"})
|
|
142
|
+
try:
|
|
143
|
+
name = json.loads(raw)["model"]
|
|
144
|
+
except (ValueError, KeyError, TypeError):
|
|
145
|
+
return self._json(400, {"error": "expected JSON with a model field"})
|
|
146
|
+
if not isinstance(name, str) or not MODEL_NAME.match(name) or ".." in name:
|
|
147
|
+
return self._json(400, {"error": "that is not a listening model name"})
|
|
148
|
+
with swapping:
|
|
149
|
+
try:
|
|
150
|
+
fresh = load_stt(name) # may take minutes the first time (download); the old model keeps listening meanwhile
|
|
151
|
+
except Exception as e:
|
|
152
|
+
return self._json(500, {"error": str(e) or "the listening model could not be loaded"})
|
|
153
|
+
engines.stt = fresh
|
|
154
|
+
self._json(200, {"ok": True, "model": name})
|
|
155
|
+
|
|
121
156
|
def _embed(self) -> None:
|
|
122
157
|
audio = self._read_body()
|
|
123
158
|
if audio is None:
|