@agentprojectcontext/apx 1.42.2 → 1.44.0
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/package.json +1 -1
- package/src/core/config/index.js +2 -0
- package/src/core/config/redact.js +2 -0
- package/src/core/desktop/process.js +126 -0
- package/src/core/voice/stt-hardware.js +87 -0
- package/src/core/voice/stt-models.js +97 -0
- package/src/core/voice/transcription.js +147 -16
- package/src/host/daemon/api/desktop.js +54 -8
- package/src/host/daemon/api/transcribe.js +40 -1
- package/src/host/daemon/whisper-server.js +18 -8
- package/src/host/daemon/whisper-server.py +71 -44
- package/src/interfaces/cli/commands/desktop.js +13 -68
- package/src/interfaces/desktop/main.js +32 -4
- package/src/interfaces/desktop/renderer.js +26 -5
- package/src/interfaces/web/dist/assets/index-BAKk7d_M.css +1 -0
- package/src/interfaces/web/dist/assets/index-Cjj_d3SA.js +656 -0
- package/src/interfaces/web/dist/assets/index-Cjj_d3SA.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/Pager.tsx +88 -0
- package/src/interfaces/web/src/components/ShortcutInput.tsx +156 -0
- package/src/interfaces/web/src/components/voice/VoiceSttCard.tsx +101 -5
- package/src/interfaces/web/src/i18n/en.ts +33 -2
- package/src/interfaces/web/src/i18n/es.ts +33 -2
- package/src/interfaces/web/src/lib/api/desktop.ts +28 -0
- package/src/interfaces/web/src/lib/api/voice.ts +26 -2
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +4 -1
- package/src/interfaces/web/src/screens/base/SessionsTab.tsx +4 -1
- package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +55 -3
- package/src/interfaces/web/src/screens/modules/DesktopScreen.tsx +98 -36
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +4 -1
- package/src/interfaces/web/dist/assets/index-BReF4_xV.js +0 -646
- package/src/interfaces/web/dist/assets/index-BReF4_xV.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +0 -1
|
@@ -2,10 +2,49 @@
|
|
|
2
2
|
// Raw audio bytes in the body. Headers:
|
|
3
3
|
// X-Audio-Format webm | ogg | wav | mp3 (defaults to webm)
|
|
4
4
|
// X-Language ISO code or "auto"
|
|
5
|
-
// X-Provider auto | local | openai (overrides config)
|
|
5
|
+
// X-Provider auto | local | openai | custom (overrides config)
|
|
6
6
|
//
|
|
7
7
|
// Shared by overlay, telegram voice messages, and any external caller.
|
|
8
8
|
export function register(app) {
|
|
9
|
+
// GET /transcribe/providers — STT engine list + availability for the web
|
|
10
|
+
// admin (mirror of /tts/providers). local = embedded faster-whisper;
|
|
11
|
+
// openai = cloud Whisper; custom = any OpenAI-compatible server (mlx-audio
|
|
12
|
+
// on Metal, a Radeon/NVIDIA box on the LAN, a remote endpoint).
|
|
13
|
+
app.get("/transcribe/providers", async (_req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const { readConfig } = await import("#core/config/index.js");
|
|
16
|
+
const { listSttProviders } = await import("#core/voice/transcription.js");
|
|
17
|
+
res.json(listSttProviders(readConfig()));
|
|
18
|
+
} catch (e) {
|
|
19
|
+
res.status(500).json({ error: e.message });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// GET /transcribe/hardware — detected machine + the recommended local backend
|
|
24
|
+
// (mlx on Apple Silicon, faster-whisper cuda on NVIDIA, else CPU). Drives the
|
|
25
|
+
// "engine adapts itself" UX in the web admin.
|
|
26
|
+
app.get("/transcribe/hardware", async (_req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const { detectHardware, recommendStt } = await import("#core/voice/stt-hardware.js");
|
|
29
|
+
const hw = detectHardware();
|
|
30
|
+
res.json({ hardware: hw, recommended: recommendStt(hw) });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
res.status(500).json({ error: e.message });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// GET /transcribe/models?backend=faster|mlx — model catalog with on-disk
|
|
37
|
+
// status (downloaded? size) for the model-manager UI.
|
|
38
|
+
app.get("/transcribe/models", async (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const backend = String(req.query.backend || "faster");
|
|
41
|
+
const { listSttModels } = await import("#core/voice/stt-models.js");
|
|
42
|
+
res.json({ backend, models: listSttModels(backend) });
|
|
43
|
+
} catch (e) {
|
|
44
|
+
res.status(500).json({ error: e.message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
9
48
|
// GET /transcribe/warmup — load the local whisper model (if needed) and reset
|
|
10
49
|
// its idle watchdog. Callers (e.g. the desktop window) ping this while open so
|
|
11
50
|
// the first real utterance doesn't pay the cold-load cost.
|
|
@@ -23,6 +23,7 @@ const WHISPER_SERVER = path.join(__dirname, "whisper-server.py");
|
|
|
23
23
|
|
|
24
24
|
let _serverProcess = null;
|
|
25
25
|
let _serverModel = null;
|
|
26
|
+
let _serverBackend = null; // "faster" | "mlx" — restart when this changes too
|
|
26
27
|
|
|
27
28
|
function _sleep(ms) {
|
|
28
29
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -39,14 +40,14 @@ async function _isServerHealthy() {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
async function
|
|
43
|
+
async function _serverHealthInfo() {
|
|
43
44
|
try {
|
|
44
45
|
const res = await fetch(`http://127.0.0.1:${WHISPER_LOCAL_PORT}/health`, {
|
|
45
46
|
signal: AbortSignal.timeout(800),
|
|
46
47
|
});
|
|
47
48
|
if (!res.ok) return null;
|
|
48
49
|
const j = await res.json();
|
|
49
|
-
return j?.model || null;
|
|
50
|
+
return { model: j?.model || null, backend: j?.backend || "faster" };
|
|
50
51
|
} catch {
|
|
51
52
|
return null;
|
|
52
53
|
}
|
|
@@ -82,17 +83,20 @@ async function _killOrphanWhisper() {
|
|
|
82
83
|
|
|
83
84
|
export async function ensureWhisperServer(opts) {
|
|
84
85
|
const model = opts.model || DEFAULT_LOCAL.model;
|
|
86
|
+
const backend = opts.backend || "faster";
|
|
85
87
|
|
|
86
|
-
if (_serverProcess && _serverModel === model) {
|
|
88
|
+
if (_serverProcess && _serverModel === model && _serverBackend === backend) {
|
|
87
89
|
if (await _isServerHealthy()) return;
|
|
88
90
|
_serverProcess = null;
|
|
89
91
|
_serverModel = null;
|
|
92
|
+
_serverBackend = null;
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
if (!_serverProcess) {
|
|
93
|
-
const existing = await
|
|
94
|
-
if (existing === model) {
|
|
96
|
+
const existing = await _serverHealthInfo();
|
|
97
|
+
if (existing && existing.model === model && existing.backend === backend) {
|
|
95
98
|
_serverModel = model;
|
|
99
|
+
_serverBackend = backend;
|
|
96
100
|
return;
|
|
97
101
|
}
|
|
98
102
|
if (existing) {
|
|
@@ -104,16 +108,18 @@ export async function ensureWhisperServer(opts) {
|
|
|
104
108
|
try { _serverProcess.kill(); } catch {}
|
|
105
109
|
_serverProcess = null;
|
|
106
110
|
_serverModel = null;
|
|
111
|
+
_serverBackend = null;
|
|
107
112
|
await _sleep(300);
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
await _spawnWhisper(opts, model, /* retried */ false);
|
|
115
|
+
await _spawnWhisper(opts, model, backend, /* retried */ false);
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
async function _spawnWhisper(opts, model, retried) {
|
|
118
|
+
async function _spawnWhisper(opts, model, backend, retried) {
|
|
114
119
|
const args = [
|
|
115
120
|
WHISPER_SERVER,
|
|
116
121
|
"--port", String(WHISPER_LOCAL_PORT),
|
|
122
|
+
"--backend", String(backend || "faster"),
|
|
117
123
|
"--model", model,
|
|
118
124
|
"--device", String(opts.device || DEFAULT_LOCAL.device),
|
|
119
125
|
"--compute-type", String(opts.compute_type || DEFAULT_LOCAL.compute_type),
|
|
@@ -127,11 +133,13 @@ async function _spawnWhisper(opts, model, retried) {
|
|
|
127
133
|
|
|
128
134
|
_serverProcess = proc;
|
|
129
135
|
_serverModel = model;
|
|
136
|
+
_serverBackend = backend;
|
|
130
137
|
|
|
131
138
|
proc.on("exit", () => {
|
|
132
139
|
if (_serverProcess === proc) {
|
|
133
140
|
_serverProcess = null;
|
|
134
141
|
_serverModel = null;
|
|
142
|
+
_serverBackend = null;
|
|
135
143
|
}
|
|
136
144
|
});
|
|
137
145
|
|
|
@@ -167,8 +175,9 @@ async function _spawnWhisper(opts, model, retried) {
|
|
|
167
175
|
if (!retried && /address already in use|errno 48|eaddrinuse/i.test(msg)) {
|
|
168
176
|
_serverProcess = null;
|
|
169
177
|
_serverModel = null;
|
|
178
|
+
_serverBackend = null;
|
|
170
179
|
await _killOrphanWhisper();
|
|
171
|
-
return _spawnWhisper(opts, model, /* retried */ true);
|
|
180
|
+
return _spawnWhisper(opts, model, backend, /* retried */ true);
|
|
172
181
|
}
|
|
173
182
|
throw e;
|
|
174
183
|
}
|
|
@@ -210,6 +219,7 @@ export async function shutdownWhisperServer() {
|
|
|
210
219
|
try { _serverProcess.kill(); } catch {}
|
|
211
220
|
_serverProcess = null;
|
|
212
221
|
_serverModel = null;
|
|
222
|
+
_serverBackend = null;
|
|
213
223
|
} else {
|
|
214
224
|
try {
|
|
215
225
|
await fetch(`http://127.0.0.1:${WHISPER_LOCAL_PORT}/shutdown`, {
|
|
@@ -39,6 +39,9 @@ def _touch():
|
|
|
39
39
|
_last_used = time.monotonic()
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
_mlx_loaded = False # mlx_whisper caches models internally; we just track readiness
|
|
43
|
+
|
|
44
|
+
|
|
42
45
|
def _load_model_if_needed(model_name, device, compute_type):
|
|
43
46
|
global _model, _model_name
|
|
44
47
|
if _model is not None and _model_name == model_name:
|
|
@@ -51,11 +54,61 @@ def _load_model_if_needed(model_name, device, compute_type):
|
|
|
51
54
|
return m
|
|
52
55
|
|
|
53
56
|
|
|
57
|
+
def _warmup_model():
|
|
58
|
+
"""Eagerly load the active backend's model into RAM. Returns True if loaded."""
|
|
59
|
+
global _mlx_loaded
|
|
60
|
+
if _Handler.backend == "mlx":
|
|
61
|
+
import mlx_whisper # noqa: F401 (raises ImportError if the stack is missing)
|
|
62
|
+
try:
|
|
63
|
+
from mlx_whisper.load_models import load_model
|
|
64
|
+
load_model(_Handler.model_name)
|
|
65
|
+
_mlx_loaded = True
|
|
66
|
+
except Exception:
|
|
67
|
+
pass # first transcribe will load it lazily
|
|
68
|
+
return _mlx_loaded
|
|
69
|
+
_load_model_if_needed(_Handler.model_name, _Handler.device, _Handler.compute_type)
|
|
70
|
+
return _model is not None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _transcribe_file(audio_path, language, beam_size):
|
|
74
|
+
"""Backend-agnostic transcription → result dict. Raises on failure."""
|
|
75
|
+
global _mlx_loaded
|
|
76
|
+
if _Handler.backend == "mlx":
|
|
77
|
+
import mlx_whisper
|
|
78
|
+
kw = {"path_or_hf_repo": _Handler.model_name}
|
|
79
|
+
if language:
|
|
80
|
+
kw["language"] = language
|
|
81
|
+
r = mlx_whisper.transcribe(audio_path, **kw)
|
|
82
|
+
_mlx_loaded = True
|
|
83
|
+
return {
|
|
84
|
+
"ok": True,
|
|
85
|
+
"text": (r.get("text") or "").strip(),
|
|
86
|
+
"language": r.get("language"),
|
|
87
|
+
"language_probability": None,
|
|
88
|
+
"duration": None,
|
|
89
|
+
"model": _Handler.model_name,
|
|
90
|
+
"compute_type": "mlx-metal",
|
|
91
|
+
}
|
|
92
|
+
m = _load_model_if_needed(_Handler.model_name, _Handler.device, _Handler.compute_type)
|
|
93
|
+
segments, info = m.transcribe(audio_path, beam_size=beam_size, language=language)
|
|
94
|
+
text = " ".join(seg.text.strip() for seg in segments).strip()
|
|
95
|
+
return {
|
|
96
|
+
"ok": True,
|
|
97
|
+
"text": text,
|
|
98
|
+
"language": info.language,
|
|
99
|
+
"language_probability": round(info.language_probability, 4),
|
|
100
|
+
"duration": round(info.duration, 2) if hasattr(info, "duration") else None,
|
|
101
|
+
"model": _model_name,
|
|
102
|
+
"compute_type": _Handler.compute_type,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
54
106
|
# ---------------------------------------------------------------------------
|
|
55
107
|
# HTTP handler
|
|
56
108
|
# ---------------------------------------------------------------------------
|
|
57
109
|
|
|
58
110
|
class _Handler(BaseHTTPRequestHandler):
|
|
111
|
+
backend = "faster" # "faster" (CTranslate2, CPU/CUDA) | "mlx" (Apple Metal)
|
|
59
112
|
model_name = "small"
|
|
60
113
|
device = "cpu"
|
|
61
114
|
compute_type = "int8"
|
|
@@ -89,10 +142,12 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
89
142
|
def do_GET(self):
|
|
90
143
|
if self.path == "/health":
|
|
91
144
|
_touch()
|
|
145
|
+
loaded = _mlx_loaded if _Handler.backend == "mlx" else (_model is not None)
|
|
92
146
|
self._send_json(200, {
|
|
93
147
|
"ok": True,
|
|
148
|
+
"backend": _Handler.backend,
|
|
94
149
|
"model": _model_name or _Handler.model_name,
|
|
95
|
-
"loaded":
|
|
150
|
+
"loaded": loaded,
|
|
96
151
|
})
|
|
97
152
|
elif self.path == "/warmup":
|
|
98
153
|
# Eagerly load the model into RAM (no audio needed) and reset the
|
|
@@ -101,8 +156,10 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
101
156
|
_touch()
|
|
102
157
|
with _model_lock:
|
|
103
158
|
try:
|
|
104
|
-
|
|
105
|
-
self._send_json(200, {"ok": True, "loaded":
|
|
159
|
+
loaded = _warmup_model()
|
|
160
|
+
self._send_json(200, {"ok": True, "loaded": loaded, "model": _Handler.model_name, "backend": _Handler.backend})
|
|
161
|
+
except ImportError as e:
|
|
162
|
+
self._send_json(500, {"ok": False, "error": f"{_Handler.backend} backend not installed: {e}"})
|
|
106
163
|
except Exception as e:
|
|
107
164
|
self._send_json(500, {"ok": False, "error": f"model load failed: {e}"})
|
|
108
165
|
else:
|
|
@@ -124,29 +181,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
124
181
|
beam_size = int(self.headers.get("X-Beam-Size") or 3)
|
|
125
182
|
|
|
126
183
|
with _model_lock:
|
|
127
|
-
try:
|
|
128
|
-
m = _load_model_if_needed(_Handler.model_name, _Handler.device, _Handler.compute_type)
|
|
129
|
-
except ImportError:
|
|
130
|
-
self._send_json(500, {"ok": False, "error": "faster-whisper not installed"})
|
|
131
|
-
return
|
|
132
|
-
except Exception as e:
|
|
133
|
-
self._send_json(500, {"ok": False, "error": f"model load failed: {e}"})
|
|
134
|
-
return
|
|
135
|
-
|
|
136
184
|
import tempfile
|
|
137
185
|
tmp = tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False)
|
|
138
186
|
try:
|
|
139
187
|
tmp.write(audio_bytes)
|
|
140
188
|
tmp.close()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
self._send_json(
|
|
144
|
-
"ok": True, "text": text,
|
|
145
|
-
"language": info.language,
|
|
146
|
-
"language_probability": round(info.language_probability, 4),
|
|
147
|
-
"duration": round(info.duration, 2) if hasattr(info, "duration") else None,
|
|
148
|
-
"model": _model_name,
|
|
149
|
-
})
|
|
189
|
+
self._send_json(200, _transcribe_file(tmp.name, language, beam_size))
|
|
190
|
+
except ImportError as e:
|
|
191
|
+
self._send_json(500, {"ok": False, "error": f"{_Handler.backend} backend not installed: {e}"})
|
|
150
192
|
except Exception as e:
|
|
151
193
|
self._send_json(500, {"ok": False, "error": f"chunk transcription failed: {e}"})
|
|
152
194
|
finally:
|
|
@@ -168,29 +210,11 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
168
210
|
|
|
169
211
|
with _model_lock:
|
|
170
212
|
try:
|
|
171
|
-
|
|
172
|
-
except ImportError:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
})
|
|
177
|
-
return
|
|
178
|
-
except Exception as e:
|
|
179
|
-
self._send_json(500, {"ok": False, "error": f"model load failed: {e}"})
|
|
180
|
-
return
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
segments, info = m.transcribe(audio_path, beam_size=beam_size, language=language)
|
|
184
|
-
text = " ".join(seg.text.strip() for seg in segments).strip()
|
|
185
|
-
self._send_json(200, {
|
|
186
|
-
"ok": True,
|
|
187
|
-
"text": text,
|
|
188
|
-
"language": info.language,
|
|
189
|
-
"language_probability": round(info.language_probability, 4),
|
|
190
|
-
"duration": round(info.duration, 2),
|
|
191
|
-
"model": _model_name,
|
|
192
|
-
"compute_type": _Handler.compute_type,
|
|
193
|
-
})
|
|
213
|
+
self._send_json(200, _transcribe_file(audio_path, language, beam_size))
|
|
214
|
+
except ImportError as e:
|
|
215
|
+
hint = ("pip3 install faster-whisper" if _Handler.backend == "faster"
|
|
216
|
+
else "pip3 install mlx-whisper")
|
|
217
|
+
self._send_json(500, {"ok": False, "error": f"{_Handler.backend} backend not installed — run: {hint} ({e})"})
|
|
194
218
|
except Exception as e:
|
|
195
219
|
self._send_json(500, {"ok": False, "error": f"transcription failed: {e}"})
|
|
196
220
|
|
|
@@ -231,12 +255,14 @@ def main():
|
|
|
231
255
|
|
|
232
256
|
parser = argparse.ArgumentParser(description="Persistent APX Whisper server")
|
|
233
257
|
parser.add_argument("--port", type=int, default=18765)
|
|
258
|
+
parser.add_argument("--backend", default="faster", choices=["faster", "mlx"])
|
|
234
259
|
parser.add_argument("--model", default="small")
|
|
235
260
|
parser.add_argument("--device", default="cpu")
|
|
236
261
|
parser.add_argument("--compute-type", dest="compute_type", default="int8")
|
|
237
262
|
parser.add_argument("--idle-minutes", dest="idle_minutes", type=int, default=10)
|
|
238
263
|
args = parser.parse_args()
|
|
239
264
|
|
|
265
|
+
_Handler.backend = args.backend
|
|
240
266
|
_Handler.model_name = args.model
|
|
241
267
|
_Handler.device = args.device
|
|
242
268
|
_Handler.compute_type = args.compute_type
|
|
@@ -252,6 +278,7 @@ def main():
|
|
|
252
278
|
print(json.dumps({
|
|
253
279
|
"status": "ready",
|
|
254
280
|
"port": args.port,
|
|
281
|
+
"backend": args.backend,
|
|
255
282
|
"model": args.model,
|
|
256
283
|
"idle_minutes": args.idle_minutes,
|
|
257
284
|
}), flush=True)
|
|
@@ -16,19 +16,26 @@ import {
|
|
|
16
16
|
WIN_RUN_KEY,
|
|
17
17
|
WIN_RUN_NAME,
|
|
18
18
|
} from "#core/desktop/autostart.js";
|
|
19
|
+
import {
|
|
20
|
+
DESKTOP_MAIN,
|
|
21
|
+
readPid, writePid, clearPid, pidAlive, isDesktopRunning,
|
|
22
|
+
findElectron as _findElectron,
|
|
23
|
+
buildElectronSpawn as _buildElectronSpawn,
|
|
24
|
+
startDesktopDetached,
|
|
25
|
+
stopDesktop,
|
|
26
|
+
} from "#core/desktop/process.js";
|
|
19
27
|
|
|
20
28
|
// Re-exports — kept so existing tests (tests/desktop-autostart.test.js)
|
|
21
29
|
// can still import these directly from the CLI module.
|
|
22
30
|
export const getApxRunner = _getApxRunner;
|
|
23
31
|
export const buildPlist = _buildPlist;
|
|
24
32
|
export const autostartIsOn = _autostartIsOn;
|
|
33
|
+
export const findElectron = _findElectron;
|
|
34
|
+
export const buildElectronSpawn = _buildElectronSpawn;
|
|
25
35
|
|
|
26
36
|
const __filename = fileURLToPath(import.meta.url);
|
|
27
37
|
const __dirname = path.dirname(__filename);
|
|
28
38
|
|
|
29
|
-
const DESKTOP_MAIN = path.resolve(__dirname, "../../desktop/main.js");
|
|
30
|
-
const DESKTOP_PID = path.join(os.homedir(), ".apx", "desktop.pid");
|
|
31
|
-
|
|
32
39
|
// ── ANSI ─────────────────────────────────────────────────────────────────────
|
|
33
40
|
const c = { reset:"\x1b[0m", bold:"\x1b[1m", dim:"\x1b[2m", green:"\x1b[32m",
|
|
34
41
|
red:"\x1b[31m", yellow:"\x1b[33m", cyan:"\x1b[36m", gray:"\x1b[90m" };
|
|
@@ -38,71 +45,9 @@ const fmt = {
|
|
|
38
45
|
cyan:(s)=>`${c.cyan}${s}${c.reset}`, gray:(s)=>`${c.gray}${s}${c.reset}`,
|
|
39
46
|
};
|
|
40
47
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
try { return parseInt(fs.readFileSync(DESKTOP_PID, "utf8").trim(), 10); } catch { return null; }
|
|
45
|
-
}
|
|
46
|
-
function writePid(pid) {
|
|
47
|
-
fs.mkdirSync(path.dirname(DESKTOP_PID), { recursive: true });
|
|
48
|
-
fs.writeFileSync(DESKTOP_PID, String(pid));
|
|
49
|
-
}
|
|
50
|
-
function clearPid() { try { fs.unlinkSync(DESKTOP_PID); } catch {} }
|
|
51
|
-
function pidAlive(pid) {
|
|
52
|
-
if (!pid) return false;
|
|
53
|
-
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Validate that an electron candidate actually runs (a pnpm shim can exist as a
|
|
57
|
-
// file while its underlying package was never built — `--version` smokes that out).
|
|
58
|
-
function electronRuns(cmd, argv) {
|
|
59
|
-
try {
|
|
60
|
-
execFileSync(cmd, argv, { stdio: "ignore", timeout: 5000 });
|
|
61
|
-
return true;
|
|
62
|
-
} catch { return false; }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Returns a descriptor used by buildElectronSpawn():
|
|
66
|
-
// absolute path to a real electron binary,
|
|
67
|
-
// absolute path to electron's cli.js (".js" → run via node),
|
|
68
|
-
// "npx" as a last-resort fallback (downloads/uses electron via npx).
|
|
69
|
-
// Never returns null — npx is always attempted so the user gets a real error
|
|
70
|
-
// from the spawn (and a one-time download) rather than a silent no-op.
|
|
71
|
-
export function findElectron() {
|
|
72
|
-
// commands/ is 4 levels under the project root: src/interfaces/cli/commands/
|
|
73
|
-
const root = path.resolve(__dirname, "..", "..", "..", "..");
|
|
74
|
-
const bin = path.join(root, "node_modules", ".bin", "electron");
|
|
75
|
-
// The .bin shim is a shell wrapper that `exec node …`. Under launchd's
|
|
76
|
-
// minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) `node` isn't found, so the
|
|
77
|
-
// shim fails. We try it first (cheap, works for terminal use) and then fall
|
|
78
|
-
// back to invoking electron's cli.js directly with process.execPath, which
|
|
79
|
-
// is launchd-safe.
|
|
80
|
-
if (fs.existsSync(bin) && electronRuns(bin, ["--version"])) return bin;
|
|
81
|
-
|
|
82
|
-
const cli = path.join(root, "node_modules", "electron", "cli.js");
|
|
83
|
-
if (fs.existsSync(cli) && electronRuns(process.execPath, [cli, "--version"])) return cli;
|
|
84
|
-
|
|
85
|
-
// Global electron on PATH (works from terminal, usually not from launchd)
|
|
86
|
-
try {
|
|
87
|
-
const which = execFileSync("which", ["electron"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
88
|
-
if (which && electronRuns(which, ["--version"])) return which;
|
|
89
|
-
} catch {}
|
|
90
|
-
|
|
91
|
-
// Last resort: npx (pulls electron if absent). Will ENOENT under launchd if
|
|
92
|
-
// npx isn't on PATH — that's why we try cli.js BEFORE this.
|
|
93
|
-
return "npx";
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Turn a findElectron() descriptor + the app entry into a { cmd, argv } pair.
|
|
97
|
-
export function buildElectronSpawn(descriptor, mainPath, port) {
|
|
98
|
-
if (descriptor === "npx") {
|
|
99
|
-
return { cmd: "npx", argv: ["-y", "electron", mainPath, "--port", port] };
|
|
100
|
-
}
|
|
101
|
-
if (descriptor.endsWith(".js")) {
|
|
102
|
-
return { cmd: process.execPath, argv: [descriptor, mainPath, "--port", port] };
|
|
103
|
-
}
|
|
104
|
-
return { cmd: descriptor, argv: [mainPath, "--port", port] };
|
|
105
|
-
}
|
|
48
|
+
// PID + electron-resolution helpers live in #core/desktop/process.js (shared
|
|
49
|
+
// with the daemon's /desktop/{start,stop} endpoints). findElectron and
|
|
50
|
+
// buildElectronSpawn are re-exported above for the existing tests.
|
|
106
51
|
|
|
107
52
|
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
108
53
|
|
|
@@ -99,9 +99,12 @@ function getTheme() {
|
|
|
99
99
|
try {
|
|
100
100
|
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
101
101
|
const t = cfg?.desktop?.theme;
|
|
102
|
-
if (t === "light" || t === "dark") return t;
|
|
102
|
+
if (t === "light" || t === "dark" || t === "system") return t;
|
|
103
103
|
} catch {}
|
|
104
|
-
|
|
104
|
+
// "system" follows the OS appearance (the renderer resolves it via
|
|
105
|
+
// prefers-color-scheme). It's the default so a fresh install matches the
|
|
106
|
+
// user's macOS/Windows light/dark setting out of the box.
|
|
107
|
+
return "system";
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
// Resolve the agent's display name from ~/.apx/identity.json + config.
|
|
@@ -316,6 +319,25 @@ function hideOverlay() {
|
|
|
316
319
|
if (isRecording) stopRecording();
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
// Soft-restart the floating window: re-read ~/.apx/config.json, move the window
|
|
323
|
+
// to the (possibly new) configured position, and reload the renderer so it
|
|
324
|
+
// re-applies theme/position/shortcut. Triggered by the web admin's Restart
|
|
325
|
+
// button via a "reload" WS event — far cheaper than killing + relaunching the
|
|
326
|
+
// Electron process (which would drop the tray + global shortcut). Recreates the
|
|
327
|
+
// window if it was closed.
|
|
328
|
+
function reloadDesktopWindow() {
|
|
329
|
+
try {
|
|
330
|
+
if (!mainWindow) { createWindow(); showOverlay(); return; }
|
|
331
|
+
const [, currentH] = mainWindow.getSize();
|
|
332
|
+
const origin = getWindowOrigin(currentH);
|
|
333
|
+
mainWindow.setPosition(origin.x, origin.y);
|
|
334
|
+
mainWindow.webContents.reload();
|
|
335
|
+
showOverlay();
|
|
336
|
+
} catch (e) {
|
|
337
|
+
console.warn("desktop: reload failed —", e.message);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
// ---------------------------------------------------------------------------
|
|
320
342
|
// Global shortcut: Cmd/Ctrl+Shift+Space toggles recording
|
|
321
343
|
// ---------------------------------------------------------------------------
|
|
@@ -527,8 +549,10 @@ function transcribeChunk(buf, format, language) {
|
|
|
527
549
|
"Content-Length": buf.length,
|
|
528
550
|
"X-Audio-Format": format,
|
|
529
551
|
"X-Language": language,
|
|
530
|
-
//
|
|
531
|
-
|
|
552
|
+
// No X-Provider override: the desktop honours the configured STT engine
|
|
553
|
+
// (transcription.provider in ~/.apx/config.json) — local faster-whisper,
|
|
554
|
+
// OpenAI cloud, or a custom OpenAI-compatible server (mlx-audio / a
|
|
555
|
+
// Radeon/NVIDIA box on the LAN). Set it in the web admin → /m/voice.
|
|
532
556
|
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
|
533
557
|
},
|
|
534
558
|
};
|
|
@@ -585,6 +609,10 @@ function connectDaemon() {
|
|
|
585
609
|
wsConn.on("message", (raw) => {
|
|
586
610
|
let msg;
|
|
587
611
|
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
612
|
+
// "reload" is a control event from the web admin's Restart button (POST
|
|
613
|
+
// /desktop/restart). Re-read config, reposition, and soft-reload the
|
|
614
|
+
// renderer so theme/position changes apply without killing the process.
|
|
615
|
+
if (msg && msg.type === "reload") { reloadDesktopWindow(); return; }
|
|
588
616
|
// Forward all daemon events to the renderer
|
|
589
617
|
mainWindow?.webContents.send("daemon-event", msg);
|
|
590
618
|
});
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
let turnWatchdog = null; // flushes the queue if a segment's TTS hangs
|
|
85
85
|
|
|
86
86
|
let history = []; // [{role:'user'|'assistant', content}] sent to daemon for context
|
|
87
|
-
let theme = "light"
|
|
87
|
+
let theme = "system"; // "light" | "dark" | "system" (config value, pre-resolution)
|
|
88
88
|
let position = "right";
|
|
89
89
|
let agentName = "Superagente"; // overwritten from config on first render
|
|
90
90
|
|
|
@@ -161,20 +161,20 @@
|
|
|
161
161
|
// the agent name stays wrong until the user changes mode.
|
|
162
162
|
let configReady = false;
|
|
163
163
|
Promise.all([
|
|
164
|
-
window.apx?.getTheme?.() ?? "
|
|
164
|
+
window.apx?.getTheme?.() ?? "system",
|
|
165
165
|
window.apx?.getPosition?.() ?? "right",
|
|
166
166
|
window.apx?.getShortcut?.() ?? "CommandOrControl+G",
|
|
167
167
|
window.apx?.getAgentName?.() ?? "Superagente",
|
|
168
168
|
window.apx?.getVoiceTiming?.() ?? null,
|
|
169
169
|
]).then(([th, pos, shortcut, name, timing]) => {
|
|
170
|
-
theme = th || "
|
|
170
|
+
theme = th || "system";
|
|
171
171
|
position = pos || "right";
|
|
172
172
|
agentName = (name && String(name).trim()) || "Superagente";
|
|
173
173
|
if (timing) {
|
|
174
174
|
if (typeof timing.silence_ms === "number") SILENCE_MS = timing.silence_ms;
|
|
175
175
|
if (typeof timing.voice_rms === "number") VOICE_RMS = timing.voice_rms;
|
|
176
176
|
}
|
|
177
|
-
|
|
177
|
+
applyTheme(theme);
|
|
178
178
|
setPosition(position);
|
|
179
179
|
captionShortcut = shortcut || "CommandOrControl+G";
|
|
180
180
|
configReady = true;
|
|
@@ -186,13 +186,34 @@
|
|
|
186
186
|
if (input) input.placeholder = `Hablá o escribí a ${agentName}…`;
|
|
187
187
|
render();
|
|
188
188
|
}).catch(() => {
|
|
189
|
-
|
|
189
|
+
applyTheme("system");
|
|
190
190
|
setPosition("right");
|
|
191
191
|
captionShortcut = "CommandOrControl+G";
|
|
192
192
|
configReady = true;
|
|
193
193
|
render();
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
// Resolve the configured theme to a concrete data-theme value. "system"
|
|
197
|
+
// follows the OS appearance via prefers-color-scheme; "light"/"dark" are
|
|
198
|
+
// used verbatim. We also subscribe to OS changes so a window left on
|
|
199
|
+
// "system" flips live when the user toggles macOS/Windows dark mode.
|
|
200
|
+
function prefersDark() {
|
|
201
|
+
try { return !!(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); }
|
|
202
|
+
catch { return false; }
|
|
203
|
+
}
|
|
204
|
+
function resolveTheme(pref) {
|
|
205
|
+
return pref === "system" ? (prefersDark() ? "dark" : "light") : (pref || "light");
|
|
206
|
+
}
|
|
207
|
+
function applyTheme(pref) {
|
|
208
|
+
theme = pref || "system";
|
|
209
|
+
document.documentElement.setAttribute("data-theme", resolveTheme(theme));
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
213
|
+
if (theme === "system") document.documentElement.setAttribute("data-theme", resolveTheme("system"));
|
|
214
|
+
});
|
|
215
|
+
} catch {}
|
|
216
|
+
|
|
196
217
|
function setPosition(p) {
|
|
197
218
|
$root.classList.remove("pos-left", "pos-center", "pos-right");
|
|
198
219
|
$root.classList.add("pos-" + p);
|