@agentprojectcontext/apx 1.15.6 → 1.17.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 +46 -5
- package/src/cli/commands/log.js +113 -0
- package/src/cli/commands/overlay.js +253 -0
- package/src/cli/commands/sys.js +88 -16
- package/src/cli/index.js +23 -1
- package/src/cli/terminal-chat/renderer.js +71 -56
- package/src/cli-ts/commands/agent.ts +173 -0
- package/src/cli-ts/commands/chat.ts +119 -0
- package/src/cli-ts/commands/daemon.ts +112 -0
- package/src/cli-ts/commands/exec.ts +109 -0
- package/src/cli-ts/commands/mcp.ts +235 -0
- package/src/cli-ts/commands/session.ts +224 -0
- package/src/cli-ts/commands/status.ts +61 -0
- package/src/cli-ts/http.ts +36 -0
- package/src/cli-ts/index.ts +73 -0
- package/src/cli-ts/ui.ts +107 -0
- package/src/core/logging.js +81 -0
- package/src/daemon/api.js +58 -0
- package/src/daemon/engines/anthropic.js +60 -1
- package/src/daemon/engines/index.js +2 -1
- package/src/daemon/engines/ollama.js +70 -3
- package/src/daemon/index.js +58 -0
- package/src/daemon/overlay-ws.js +40 -0
- package/src/daemon/plugins/index.js +2 -1
- package/src/daemon/plugins/overlay.js +177 -0
- package/src/daemon/plugins/telegram.js +15 -3
- package/src/daemon/super-agent-langchain.js +296 -0
- package/src/daemon/super-agent.js +115 -19
- package/src/daemon/transcription.js +262 -59
- package/src/daemon/whisper-server.py +57 -6
- package/src/overlay/index.html +44 -0
- package/src/overlay/main.js +480 -0
- package/src/overlay/package.json +3 -0
- package/src/overlay/preload.js +34 -0
- package/src/overlay/renderer.js +371 -0
- package/src/overlay/style.css +250 -0
- package/src/tui/_shims/cli-error.ts +6 -0
- package/src/tui/_shims/cli-logo.ts +18 -0
- package/src/tui/_shims/cli-ui.ts +1 -0
- package/src/tui/_shims/config-console-state.ts +7 -0
- package/src/tui/_shims/core-any.ts +30 -0
- package/src/tui/_shims/core-binary.ts +13 -0
- package/src/tui/_shims/core-flag.ts +3 -0
- package/src/tui/_shims/core-log.ts +14 -0
- package/src/tui/_shims/lsp-language.ts +1 -0
- package/src/tui/_shims/opencode-any.ts +135 -0
- package/src/tui/_shims/opencode-sdk-v2.ts +48 -0
- package/src/tui/_shims/plugin-tui.ts +13 -0
- package/src/tui/_shims/provider-provider.ts +10 -0
- package/src/tui/_shims/session-retry.ts +1 -0
- package/src/tui/_shims/session-schema.ts +15 -0
- package/src/tui/_shims/session-session.ts +3 -0
- package/src/tui/_shims/snapshot.ts +4 -0
- package/src/tui/_shims/tool-any.ts +18 -0
- package/src/tui/_shims/util-error.ts +7 -0
- package/src/tui/_shims/util-filesystem.ts +79 -0
- package/src/tui/_shims/util-format.ts +7 -0
- package/src/tui/_shims/util-iife.ts +3 -0
- package/src/tui/_shims/util-locale.ts +10 -0
- package/src/tui/_shims/util-process.ts +38 -0
- package/src/tui/app.tsx +783 -0
- package/src/tui/asset/charge.wav +0 -0
- package/src/tui/asset/pulse-a.wav +0 -0
- package/src/tui/asset/pulse-b.wav +0 -0
- package/src/tui/asset/pulse-c.wav +0 -0
- package/src/tui/attach.ts +100 -0
- package/src/tui/component/bg-pulse-render.ts +436 -0
- package/src/tui/component/bg-pulse.tsx +99 -0
- package/src/tui/component/border.tsx +21 -0
- package/src/tui/component/dialog-agent.tsx +31 -0
- package/src/tui/component/dialog-console-org.tsx +103 -0
- package/src/tui/component/dialog-mcp.tsx +85 -0
- package/src/tui/component/dialog-model.tsx +175 -0
- package/src/tui/component/dialog-provider.tsx +456 -0
- package/src/tui/component/dialog-retry-action.tsx +160 -0
- package/src/tui/component/dialog-session-delete-failed.tsx +99 -0
- package/src/tui/component/dialog-session-list.tsx +323 -0
- package/src/tui/component/dialog-session-rename.tsx +31 -0
- package/src/tui/component/dialog-skill.tsx +36 -0
- package/src/tui/component/dialog-stash.tsx +87 -0
- package/src/tui/component/dialog-status.tsx +168 -0
- package/src/tui/component/dialog-tag.tsx +44 -0
- package/src/tui/component/dialog-theme-list.tsx +50 -0
- package/src/tui/component/dialog-variant.tsx +39 -0
- package/src/tui/component/dialog-workspace-create.tsx +302 -0
- package/src/tui/component/dialog-workspace-file-changes.tsx +138 -0
- package/src/tui/component/dialog-workspace-unavailable.tsx +69 -0
- package/src/tui/component/error-component.tsx +92 -0
- package/src/tui/component/logo.tsx +896 -0
- package/src/tui/component/plugin-route-missing.tsx +14 -0
- package/src/tui/component/prompt/autocomplete.tsx +869 -0
- package/src/tui/component/prompt/cwd.ts +0 -0
- package/src/tui/component/prompt/frecency.tsx +90 -0
- package/src/tui/component/prompt/history.tsx +108 -0
- package/src/tui/component/prompt/index.tsx +1809 -0
- package/src/tui/component/prompt/part.ts +16 -0
- package/src/tui/component/prompt/stash.tsx +101 -0
- package/src/tui/component/prompt/traits.ts +35 -0
- package/src/tui/component/spinner.tsx +24 -0
- package/src/tui/component/startup-loading.tsx +63 -0
- package/src/tui/component/todo-item.tsx +32 -0
- package/src/tui/component/use-connected.tsx +9 -0
- package/src/tui/component/workspace-label.tsx +19 -0
- package/src/tui/config/cwd.ts +5 -0
- package/src/tui/config/keybind.ts +432 -0
- package/src/tui/config/tui-migrate.ts +154 -0
- package/src/tui/config/tui-schema.ts +34 -0
- package/src/tui/config/tui.ts +46 -0
- package/src/tui/context/aggregate-failures.ts +34 -0
- package/src/tui/context/args.tsx +15 -0
- package/src/tui/context/command-palette.tsx +163 -0
- package/src/tui/context/directory.ts +15 -0
- package/src/tui/context/editor-zed.ts +283 -0
- package/src/tui/context/editor.ts +468 -0
- package/src/tui/context/event-apx.ts +22 -0
- package/src/tui/context/event.ts +6 -0
- package/src/tui/context/exit.tsx +60 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/kv.tsx +81 -0
- package/src/tui/context/local.tsx +608 -0
- package/src/tui/context/path-format.tsx +39 -0
- package/src/tui/context/project-apx.tsx +48 -0
- package/src/tui/context/project.tsx +7 -0
- package/src/tui/context/prompt.tsx +18 -0
- package/src/tui/context/route.tsx +52 -0
- package/src/tui/context/sdk-apx.tsx +185 -0
- package/src/tui/context/sdk.tsx +6 -0
- package/src/tui/context/sync-apx.tsx +178 -0
- package/src/tui/context/sync-v2.tsx +16 -0
- package/src/tui/context/sync.tsx +118 -0
- package/src/tui/context/theme/aura.json +69 -0
- package/src/tui/context/theme/ayu.json +80 -0
- package/src/tui/context/theme/carbonfox.json +248 -0
- package/src/tui/context/theme/catppuccin-frappe.json +230 -0
- package/src/tui/context/theme/catppuccin-macchiato.json +230 -0
- package/src/tui/context/theme/catppuccin.json +112 -0
- package/src/tui/context/theme/cobalt2.json +225 -0
- package/src/tui/context/theme/cursor.json +249 -0
- package/src/tui/context/theme/dracula.json +219 -0
- package/src/tui/context/theme/everforest.json +241 -0
- package/src/tui/context/theme/flexoki.json +237 -0
- package/src/tui/context/theme/github.json +233 -0
- package/src/tui/context/theme/gruvbox.json +242 -0
- package/src/tui/context/theme/kanagawa.json +77 -0
- package/src/tui/context/theme/lucent-orng.json +234 -0
- package/src/tui/context/theme/material.json +235 -0
- package/src/tui/context/theme/matrix.json +77 -0
- package/src/tui/context/theme/mercury.json +252 -0
- package/src/tui/context/theme/monokai.json +221 -0
- package/src/tui/context/theme/nightowl.json +221 -0
- package/src/tui/context/theme/nord.json +223 -0
- package/src/tui/context/theme/one-dark.json +84 -0
- package/src/tui/context/theme/opencode.json +245 -0
- package/src/tui/context/theme/orng.json +249 -0
- package/src/tui/context/theme/osaka-jade.json +93 -0
- package/src/tui/context/theme/palenight.json +222 -0
- package/src/tui/context/theme/rosepine.json +234 -0
- package/src/tui/context/theme/solarized.json +223 -0
- package/src/tui/context/theme/synthwave84.json +226 -0
- package/src/tui/context/theme/tokyonight.json +243 -0
- package/src/tui/context/theme/vercel.json +245 -0
- package/src/tui/context/theme/vesper.json +218 -0
- package/src/tui/context/theme/zenburn.json +223 -0
- package/src/tui/context/theme.tsx +1247 -0
- package/src/tui/context/tui-config.tsx +9 -0
- package/src/tui/event.ts +16 -0
- package/src/tui/feature-plugins/home/footer.tsx +94 -0
- package/src/tui/feature-plugins/home/tips-view.tsx +166 -0
- package/src/tui/feature-plugins/home/tips.tsx +59 -0
- package/src/tui/feature-plugins/sidebar/context.tsx +65 -0
- package/src/tui/feature-plugins/sidebar/files.tsx +63 -0
- package/src/tui/feature-plugins/sidebar/footer.tsx +94 -0
- package/src/tui/feature-plugins/sidebar/lsp.tsx +65 -0
- package/src/tui/feature-plugins/sidebar/mcp.tsx +97 -0
- package/src/tui/feature-plugins/sidebar/todo.tsx +49 -0
- package/src/tui/feature-plugins/system/plugins.tsx +269 -0
- package/src/tui/feature-plugins/system/session-v2.tsx +1143 -0
- package/src/tui/feature-plugins/system/which-key.tsx +608 -0
- package/src/tui/keymap.tsx +166 -0
- package/src/tui/layer.ts +6 -0
- package/src/tui/plugin/api.tsx +381 -0
- package/src/tui/plugin/command-shim.ts +109 -0
- package/src/tui/plugin/internal.ts +33 -0
- package/src/tui/plugin/runtime.ts +1069 -0
- package/src/tui/plugin/slots.tsx +60 -0
- package/src/tui/routes/home.tsx +96 -0
- package/src/tui/routes/session/dialog-fork-from-timeline.tsx +76 -0
- package/src/tui/routes/session/dialog-message.tsx +108 -0
- package/src/tui/routes/session/dialog-subagent.tsx +26 -0
- package/src/tui/routes/session/dialog-timeline.tsx +47 -0
- package/src/tui/routes/session/footer.tsx +91 -0
- package/src/tui/routes/session/index.tsx +188 -0
- package/src/tui/routes/session/permission.tsx +722 -0
- package/src/tui/routes/session/question.tsx +490 -0
- package/src/tui/routes/session/sidebar.tsx +102 -0
- package/src/tui/routes/session/subagent-footer.tsx +133 -0
- package/src/tui/run.ts +84 -0
- package/src/tui/thread.ts +261 -0
- package/src/tui/tsconfig.json +40 -0
- package/src/tui/ui/dialog-alert.tsx +66 -0
- package/src/tui/ui/dialog-confirm.tsx +108 -0
- package/src/tui/ui/dialog-export-options.tsx +217 -0
- package/src/tui/ui/dialog-help.tsx +40 -0
- package/src/tui/ui/dialog-prompt.tsx +101 -0
- package/src/tui/ui/dialog-select.tsx +553 -0
- package/src/tui/ui/dialog.tsx +211 -0
- package/src/tui/ui/link.tsx +34 -0
- package/src/tui/ui/spinner.ts +368 -0
- package/src/tui/ui/toast.tsx +111 -0
- package/src/tui/util/clipboard.ts +217 -0
- package/src/tui/util/editor.ts +37 -0
- package/src/tui/util/model.ts +23 -0
- package/src/tui/util/provider-origin.ts +7 -0
- package/src/tui/util/revert-diff.ts +18 -0
- package/src/tui/util/scroll.ts +25 -0
- package/src/tui/util/selection.ts +65 -0
- package/src/tui/util/signal.ts +41 -0
- package/src/tui/util/sound.ts +156 -0
- package/src/tui/util/transcript.ts +112 -0
- package/src/tui/validate-session.ts +29 -0
- package/src/tui/win32.ts +130 -0
- package/src/tui/worker.ts +104 -0
|
@@ -64,12 +64,18 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
64
64
|
pass # suppress access log; APX daemon handles its own logging
|
|
65
65
|
|
|
66
66
|
def _send_json(self, code, body):
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
67
|
+
# Swallow BrokenPipe / ConnectionReset — these happen when the daemon
|
|
68
|
+
# times out and aborts the request before we finish responding, and
|
|
69
|
+
# they used to fill the daemon log with multi-page Python tracebacks.
|
|
70
|
+
try:
|
|
71
|
+
data = json.dumps(body).encode()
|
|
72
|
+
self.send_response(code)
|
|
73
|
+
self.send_header("Content-Type", "application/json")
|
|
74
|
+
self.send_header("Content-Length", str(len(data)))
|
|
75
|
+
self.end_headers()
|
|
76
|
+
self.wfile.write(data)
|
|
77
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
78
|
+
pass
|
|
73
79
|
|
|
74
80
|
def _read_body(self):
|
|
75
81
|
n = int(self.headers.get("Content-Length", 0))
|
|
@@ -92,6 +98,51 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
92
98
|
self._send_json(404, {"ok": False, "error": "not found"})
|
|
93
99
|
|
|
94
100
|
def do_POST(self):
|
|
101
|
+
# /transcribe_chunk reads raw bytes — must be handled BEFORE _read_body()
|
|
102
|
+
# which would consume rfile for JSON endpoints.
|
|
103
|
+
if self.path == "/transcribe_chunk":
|
|
104
|
+
_touch()
|
|
105
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
106
|
+
if content_length <= 0:
|
|
107
|
+
self._send_json(400, {"ok": False, "error": "empty body"})
|
|
108
|
+
return
|
|
109
|
+
audio_bytes = self.rfile.read(content_length)
|
|
110
|
+
audio_format = (self.headers.get("X-Audio-Format") or "webm").strip().lstrip(".")
|
|
111
|
+
language_hdr = self.headers.get("X-Language") or None
|
|
112
|
+
language = language_hdr if language_hdr and language_hdr != "auto" else None
|
|
113
|
+
beam_size = int(self.headers.get("X-Beam-Size") or 3)
|
|
114
|
+
|
|
115
|
+
with _model_lock:
|
|
116
|
+
try:
|
|
117
|
+
m = _load_model_if_needed(_Handler.model_name, _Handler.device, _Handler.compute_type)
|
|
118
|
+
except ImportError:
|
|
119
|
+
self._send_json(500, {"ok": False, "error": "faster-whisper not installed"})
|
|
120
|
+
return
|
|
121
|
+
except Exception as e:
|
|
122
|
+
self._send_json(500, {"ok": False, "error": f"model load failed: {e}"})
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
import tempfile
|
|
126
|
+
tmp = tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False)
|
|
127
|
+
try:
|
|
128
|
+
tmp.write(audio_bytes)
|
|
129
|
+
tmp.close()
|
|
130
|
+
segments, info = m.transcribe(tmp.name, beam_size=beam_size, language=language)
|
|
131
|
+
text = " ".join(seg.text.strip() for seg in segments).strip()
|
|
132
|
+
self._send_json(200, {
|
|
133
|
+
"ok": True, "text": text,
|
|
134
|
+
"language": info.language,
|
|
135
|
+
"language_probability": round(info.language_probability, 4),
|
|
136
|
+
"duration": round(info.duration, 2) if hasattr(info, "duration") else None,
|
|
137
|
+
"model": _model_name,
|
|
138
|
+
})
|
|
139
|
+
except Exception as e:
|
|
140
|
+
self._send_json(500, {"ok": False, "error": f"chunk transcription failed: {e}"})
|
|
141
|
+
finally:
|
|
142
|
+
try: os.unlink(tmp.name)
|
|
143
|
+
except Exception: pass
|
|
144
|
+
return
|
|
145
|
+
|
|
95
146
|
req = self._read_body()
|
|
96
147
|
|
|
97
148
|
if self.path == "/transcribe":
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta http-equiv="Content-Security-Policy"
|
|
6
|
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src *;">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
8
|
+
<title>APX</title>
|
|
9
|
+
<link rel="stylesheet" href="style.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app">
|
|
13
|
+
<!-- Header -->
|
|
14
|
+
<div id="header">
|
|
15
|
+
<span class="logo">APX</span>
|
|
16
|
+
<span id="status-text" class="status">Ready</span>
|
|
17
|
+
<div id="conn-badge" title="Daemon disconnected"></div>
|
|
18
|
+
<button class="btn-close" id="btn-close" title="Close (Esc)">✕</button>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<!-- Messages -->
|
|
22
|
+
<div id="messages">
|
|
23
|
+
<div id="empty-state">
|
|
24
|
+
<div class="icon">🎙</div>
|
|
25
|
+
<div>Press <kbd id="empty-shortcut-hint">⌘G</kbd> to start speaking</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Live transcription bar -->
|
|
30
|
+
<div id="live-bar">
|
|
31
|
+
<div class="rec-dot"></div>
|
|
32
|
+
<div id="live-text">Listening…</div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<!-- Hint bar -->
|
|
36
|
+
<div id="hint-bar">
|
|
37
|
+
<span class="hint"><kbd id="shortcut-hint">⌘⇧\</kbd> record</span>
|
|
38
|
+
<span class="hint"><kbd>Esc</kbd> cancel / close</span>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<script src="renderer.js"></script>
|
|
43
|
+
</body>
|
|
44
|
+
</html>
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// APX Overlay — Electron main process.
|
|
2
|
+
// Provides: system tray icon, configurable global shortcut, transparent
|
|
3
|
+
// floating chat window, WebSocket connection to APX daemon.
|
|
4
|
+
//
|
|
5
|
+
// Default shortcut: Cmd+Shift+\ (Mac) / Ctrl+Shift+\ (Win/Linux).
|
|
6
|
+
// Override in ~/.apx/config.json: "overlay": { "shortcut": "CommandOrControl+Shift+Space" }
|
|
7
|
+
//
|
|
8
|
+
// Launch via: electron src/overlay/main.js [--port 7430] [--shortcut <accel>]
|
|
9
|
+
// Or via: apx overlay start
|
|
10
|
+
|
|
11
|
+
"use strict";
|
|
12
|
+
const { app, BrowserWindow, Tray, globalShortcut, ipcMain, nativeImage, screen, Menu } = require("electron");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
const os = require("os");
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const http = require("http");
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Config from CLI args or env
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
function getArg(name) {
|
|
24
|
+
const i = args.indexOf(name);
|
|
25
|
+
return i !== -1 ? args[i + 1] : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DAEMON_PORT = parseInt(getArg("--port") || process.env.APX_PORT || "7430", 10);
|
|
29
|
+
const DAEMON_HOST = getArg("--host") || process.env.APX_HOST || "127.0.0.1";
|
|
30
|
+
const WHISPER_PORT = 18765;
|
|
31
|
+
const TOKEN_PATH = path.join(os.homedir(), ".apx", "daemon.token");
|
|
32
|
+
const CONFIG_PATH = path.join(os.homedir(), ".apx", "config.json");
|
|
33
|
+
|
|
34
|
+
// Default shortcut: Cmd/Ctrl + Shift + \ (backslash — rarely used by other apps)
|
|
35
|
+
// User can override via config overlay.shortcut or --shortcut CLI arg.
|
|
36
|
+
const DEFAULT_SHORTCUT = "CommandOrControl+G";
|
|
37
|
+
|
|
38
|
+
function readApxConfig() {
|
|
39
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { return {}; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getShortcut() {
|
|
43
|
+
const fromArg = getArg("--shortcut");
|
|
44
|
+
if (fromArg) return fromArg;
|
|
45
|
+
const cfg = readApxConfig();
|
|
46
|
+
return cfg?.overlay?.shortcut || DEFAULT_SHORTCUT;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readToken() {
|
|
50
|
+
try { return fs.readFileSync(TOKEN_PATH, "utf8").trim(); } catch { return ""; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Window size + position helpers
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const WIN_W = 420;
|
|
58
|
+
const WIN_H = 560;
|
|
59
|
+
|
|
60
|
+
function getWindowPosition() {
|
|
61
|
+
const display = screen.getPrimaryDisplay();
|
|
62
|
+
const { workArea } = display;
|
|
63
|
+
const isMac = process.platform === "darwin";
|
|
64
|
+
if (isMac) {
|
|
65
|
+
// Top-right, below menu bar (workArea.y already accounts for it)
|
|
66
|
+
return { x: workArea.x + workArea.width - WIN_W - 12, y: workArea.y + 8 };
|
|
67
|
+
}
|
|
68
|
+
// Windows/Linux: bottom-right, above taskbar
|
|
69
|
+
return {
|
|
70
|
+
x: workArea.x + workArea.width - WIN_W - 12,
|
|
71
|
+
y: workArea.y + workArea.height - WIN_H - 12,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Globals
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
let mainWindow = null;
|
|
80
|
+
let tray = null;
|
|
81
|
+
let wsConn = null; // WebSocket to daemon
|
|
82
|
+
let isRecording = false;
|
|
83
|
+
let overlayVisible = false;
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// App lifecycle
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
// On macOS, don't show in the dock — it's a tray-only utility
|
|
90
|
+
if (process.platform === "darwin") app.dock?.hide();
|
|
91
|
+
|
|
92
|
+
app.whenReady().then(() => {
|
|
93
|
+
console.log(`overlay: starting — daemon ${DAEMON_HOST}:${DAEMON_PORT} — pid ${process.pid}`);
|
|
94
|
+
try { createTray(); console.log("overlay: tray created"); }
|
|
95
|
+
catch (e) { console.error("overlay: createTray failed:", e.message); }
|
|
96
|
+
try { createWindow(); console.log("overlay: window created"); }
|
|
97
|
+
catch (e) { console.error("overlay: createWindow failed:", e.message); }
|
|
98
|
+
try { registerShortcut(); }
|
|
99
|
+
catch (e) { console.error("overlay: registerShortcut failed:", e.message); }
|
|
100
|
+
connectDaemon();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
process.on("uncaughtException", (e) => {
|
|
104
|
+
console.error("overlay: uncaught exception:", e.stack || e.message);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.on("window-all-closed", (e) => {
|
|
108
|
+
// Prevent app from quitting when window closes — keep in tray
|
|
109
|
+
e.preventDefault?.();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
app.on("will-quit", () => {
|
|
113
|
+
globalShortcut.unregisterAll();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Tray
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
// Minimal valid 16x16 transparent PNG (base64).
|
|
121
|
+
// macOS requires a real PNG for Tray — raw RGBA buffers are not accepted.
|
|
122
|
+
// We use setTitle() to display the visible symbol in the menu bar.
|
|
123
|
+
const ICON_TRANSPARENT_PNG =
|
|
124
|
+
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCS" +
|
|
125
|
+
"VQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAABl0RVh0U29" +
|
|
126
|
+
"mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAAOSURBVDiNY2Bg" +
|
|
127
|
+
"YPgPAAEEAQABZQMuAAAAAElFTkSuQmCC";
|
|
128
|
+
|
|
129
|
+
// Red dot PNG for recording state (16x16 solid red circle).
|
|
130
|
+
const ICON_RED_PNG =
|
|
131
|
+
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAASUlEQVQ4" +
|
|
132
|
+
"y2NgYGD4z0ABYBpFkKoFVAMDA8N/CiygWgMDA8N/qgygWgMDA8N/og" +
|
|
133
|
+
"ygSgMDA8N/igygWgMDA8N/BgYGBgCZJCULAAAAAElFTkSuQmCC";
|
|
134
|
+
|
|
135
|
+
function buildTrayIcon(recording) {
|
|
136
|
+
const b64 = recording ? ICON_RED_PNG : ICON_TRANSPARENT_PNG;
|
|
137
|
+
const img = nativeImage.createFromDataURL(`data:image/png;base64,${b64}`);
|
|
138
|
+
// Template image: macOS auto-adapts colour to dark/light menu bar
|
|
139
|
+
if (process.platform === "darwin") img.setTemplateImage(!recording);
|
|
140
|
+
return img;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function createTray() {
|
|
144
|
+
const icon = buildTrayIcon(false);
|
|
145
|
+
tray = new Tray(icon);
|
|
146
|
+
|
|
147
|
+
// On macOS, setTitle shows text right in the menu bar (most visible approach)
|
|
148
|
+
if (process.platform === "darwin") tray.setTitle(" ◉");
|
|
149
|
+
|
|
150
|
+
tray.setToolTip("APX Voice Overlay — click to toggle, right-click for menu");
|
|
151
|
+
|
|
152
|
+
const contextMenu = Menu.buildFromTemplate([
|
|
153
|
+
{ label: "Show / Hide", click: toggleWindow },
|
|
154
|
+
{ label: "Start Recording", click: startRecording },
|
|
155
|
+
{ type: "separator" },
|
|
156
|
+
{ label: "Quit APX Overlay", click: () => app.exit(0) },
|
|
157
|
+
]);
|
|
158
|
+
tray.setContextMenu(contextMenu);
|
|
159
|
+
tray.on("click", toggleWindow);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function updateTrayRecording(rec) {
|
|
163
|
+
if (!tray) return;
|
|
164
|
+
tray.setImage(buildTrayIcon(rec));
|
|
165
|
+
if (process.platform === "darwin") tray.setTitle(rec ? " ⏺" : " ◉");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Window
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function createWindow() {
|
|
173
|
+
const pos = getWindowPosition();
|
|
174
|
+
mainWindow = new BrowserWindow({
|
|
175
|
+
width: WIN_W,
|
|
176
|
+
height: WIN_H,
|
|
177
|
+
x: pos.x,
|
|
178
|
+
y: pos.y,
|
|
179
|
+
frame: false,
|
|
180
|
+
transparent: true,
|
|
181
|
+
alwaysOnTop: true,
|
|
182
|
+
skipTaskbar: true,
|
|
183
|
+
resizable: false,
|
|
184
|
+
hasShadow: false,
|
|
185
|
+
show: false,
|
|
186
|
+
focusable: true,
|
|
187
|
+
webPreferences: {
|
|
188
|
+
preload: path.join(__dirname, "preload.js"),
|
|
189
|
+
contextIsolation: true,
|
|
190
|
+
nodeIntegration: false,
|
|
191
|
+
// Allow getUserMedia for microphone access
|
|
192
|
+
allowRunningInsecureContent: false,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
|
197
|
+
|
|
198
|
+
mainWindow.on("blur", () => {
|
|
199
|
+
// Don't auto-hide while recording or streaming
|
|
200
|
+
if (!isRecording) {
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
if (mainWindow && !mainWindow.isFocused() && !isRecording) {
|
|
203
|
+
// Keep visible — user might be reading the response
|
|
204
|
+
}
|
|
205
|
+
}, 200);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
mainWindow.on("closed", () => { mainWindow = null; });
|
|
210
|
+
|
|
211
|
+
// ESC key handled in renderer via preload
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function toggleWindow() {
|
|
215
|
+
if (!mainWindow) { createWindow(); return; }
|
|
216
|
+
if (overlayVisible) {
|
|
217
|
+
hideOverlay();
|
|
218
|
+
} else {
|
|
219
|
+
showOverlay();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function showOverlay() {
|
|
224
|
+
if (!mainWindow) createWindow();
|
|
225
|
+
const pos = getWindowPosition();
|
|
226
|
+
mainWindow.setPosition(pos.x, pos.y);
|
|
227
|
+
mainWindow.show();
|
|
228
|
+
mainWindow.focus();
|
|
229
|
+
overlayVisible = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function hideOverlay() {
|
|
233
|
+
if (mainWindow) mainWindow.hide();
|
|
234
|
+
overlayVisible = false;
|
|
235
|
+
if (isRecording) stopRecording();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Global shortcut: Cmd/Ctrl+Shift+Space toggles recording
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
function registerShortcut() {
|
|
243
|
+
const shortcut = getShortcut();
|
|
244
|
+
const ok = globalShortcut.register(shortcut, () => {
|
|
245
|
+
if (!overlayVisible) {
|
|
246
|
+
showOverlay();
|
|
247
|
+
// Auto-start recording when opening via shortcut
|
|
248
|
+
setTimeout(startRecording, 150);
|
|
249
|
+
} else if (isRecording) {
|
|
250
|
+
stopRecording();
|
|
251
|
+
} else {
|
|
252
|
+
startRecording();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
if (!ok) {
|
|
256
|
+
console.error(`overlay: failed to register shortcut "${shortcut}". Try a different shortcut in ~/.apx/config.json: overlay.shortcut`);
|
|
257
|
+
} else {
|
|
258
|
+
console.log(`overlay: shortcut registered: ${shortcut}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Recording control
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function startRecording() {
|
|
267
|
+
if (isRecording) return;
|
|
268
|
+
isRecording = true;
|
|
269
|
+
updateTrayRecording(true);
|
|
270
|
+
mainWindow?.webContents.send("recording-start");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function stopRecording() {
|
|
274
|
+
if (!isRecording) return;
|
|
275
|
+
isRecording = false;
|
|
276
|
+
updateTrayRecording(false);
|
|
277
|
+
mainWindow?.webContents.send("recording-stop");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// IPC handlers (renderer → main)
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
// Renderer sends audio chunk for transcription
|
|
285
|
+
ipcMain.handle("transcribe-chunk", async (_event, { buffer, format, language }) => {
|
|
286
|
+
try {
|
|
287
|
+
console.log(`overlay: transcribe chunk — ${buffer.byteLength}b ${format}`);
|
|
288
|
+
const result = await transcribeChunk(Buffer.from(buffer), format || "webm", language || "auto");
|
|
289
|
+
if (result?.ok) console.log(`overlay: transcribed → "${(result.text || "").slice(0, 80)}"`);
|
|
290
|
+
else console.error("overlay: transcription error:", result?.error);
|
|
291
|
+
return result;
|
|
292
|
+
} catch (e) {
|
|
293
|
+
console.error("overlay: transcribeChunk exception:", e.message);
|
|
294
|
+
return { ok: false, error: e.message };
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Renderer sends final transcribed text to daemon
|
|
299
|
+
ipcMain.handle("send-message", async (_event, { text, previousMessages }) => {
|
|
300
|
+
console.log(`overlay: send-message → "${text.slice(0, 80)}"`);
|
|
301
|
+
return sendMessageToDaemon(text, previousMessages || []);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Renderer requests cancel
|
|
305
|
+
ipcMain.handle("cancel", async () => {
|
|
306
|
+
if (wsConn && wsConn.readyState === 1) {
|
|
307
|
+
wsConn.send(JSON.stringify({ type: "cancel" }));
|
|
308
|
+
}
|
|
309
|
+
stopRecording();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// Renderer requests close/hide
|
|
313
|
+
ipcMain.handle("close-overlay", async () => {
|
|
314
|
+
hideOverlay();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Renderer queries the configured shortcut for display
|
|
318
|
+
ipcMain.handle("get-shortcut", () => getShortcut());
|
|
319
|
+
|
|
320
|
+
// Check if the whisper server is running and the model is loaded
|
|
321
|
+
ipcMain.handle("check-whisper-ready", () => {
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
const options = {
|
|
324
|
+
hostname: "127.0.0.1",
|
|
325
|
+
port: WHISPER_PORT,
|
|
326
|
+
path: "/health",
|
|
327
|
+
method: "GET",
|
|
328
|
+
};
|
|
329
|
+
const req = http.request(options, (res) => {
|
|
330
|
+
let data = "";
|
|
331
|
+
res.on("data", (c) => data += c);
|
|
332
|
+
res.on("end", () => {
|
|
333
|
+
try {
|
|
334
|
+
const json = JSON.parse(data);
|
|
335
|
+
resolve({ ready: json.ok && json.loaded === true });
|
|
336
|
+
} catch {
|
|
337
|
+
resolve({ ready: false });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
req.on("error", () => resolve({ ready: false }));
|
|
342
|
+
req.setTimeout(800, () => { req.destroy(); resolve({ ready: false }); });
|
|
343
|
+
req.end();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Renderer requests recording toggle (ESC cancels, shortcut toggles)
|
|
348
|
+
ipcMain.handle("toggle-recording", async () => {
|
|
349
|
+
if (isRecording) stopRecording(); else startRecording();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Whisper chunk transcription — proxied through the daemon (auto-starts whisper server)
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
function transcribeChunk(buf, format, language) {
|
|
357
|
+
return new Promise((resolve, reject) => {
|
|
358
|
+
const token = readToken();
|
|
359
|
+
const options = {
|
|
360
|
+
hostname: DAEMON_HOST,
|
|
361
|
+
port: DAEMON_PORT,
|
|
362
|
+
path: "/transcribe/chunk",
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: {
|
|
365
|
+
"Content-Type": "application/octet-stream",
|
|
366
|
+
"Content-Length": buf.length,
|
|
367
|
+
"X-Audio-Format": format,
|
|
368
|
+
"X-Language": language,
|
|
369
|
+
// Overlay is real-time → local whisper only. Never fall back to OpenAI.
|
|
370
|
+
"X-Provider": "local",
|
|
371
|
+
...(token ? { "Authorization": `Bearer ${token}` } : {}),
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
const req = http.request(options, (res) => {
|
|
375
|
+
let data = "";
|
|
376
|
+
res.on("data", (c) => data += c);
|
|
377
|
+
res.on("end", () => {
|
|
378
|
+
try { resolve(JSON.parse(data)); }
|
|
379
|
+
catch { reject(new Error("bad json from daemon")); }
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
req.on("error", reject);
|
|
383
|
+
req.setTimeout(30000, () => { req.destroy(); reject(new Error("transcription timeout")); });
|
|
384
|
+
req.write(buf);
|
|
385
|
+
req.end();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Daemon communication
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
function connectDaemon() {
|
|
394
|
+
// Lazy-load ws from the APX node_modules (co-located)
|
|
395
|
+
let WS;
|
|
396
|
+
try {
|
|
397
|
+
WS = require("ws");
|
|
398
|
+
} catch {
|
|
399
|
+
console.warn("overlay: 'ws' module not found — WebSocket disabled. Install with: npm install ws");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const token = readToken();
|
|
404
|
+
const url = `ws://${DAEMON_HOST}:${DAEMON_PORT}/overlay/ws`;
|
|
405
|
+
|
|
406
|
+
function connect() {
|
|
407
|
+
try {
|
|
408
|
+
wsConn = new WS(url, {
|
|
409
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
wsConn.on("open", () => {
|
|
413
|
+
console.log("overlay: connected to daemon");
|
|
414
|
+
resetReconnectDelay();
|
|
415
|
+
mainWindow?.webContents.send("daemon-connected");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
wsConn.on("message", (raw) => {
|
|
419
|
+
let msg;
|
|
420
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
421
|
+
// Forward all daemon events to the renderer
|
|
422
|
+
mainWindow?.webContents.send("daemon-event", msg);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
wsConn.on("close", () => {
|
|
426
|
+
wsConn = null;
|
|
427
|
+
mainWindow?.webContents.send("daemon-disconnected");
|
|
428
|
+
scheduleReconnect();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
wsConn.on("error", (e) => {
|
|
432
|
+
console.warn("overlay ws error:", e.message);
|
|
433
|
+
});
|
|
434
|
+
} catch (e) {
|
|
435
|
+
console.warn("overlay: connect failed —", e.message);
|
|
436
|
+
scheduleReconnect();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Exponential backoff with cap: 1s → 2s → 4s → … → 30s. Resets to 1s
|
|
441
|
+
// after a successful open() (see below).
|
|
442
|
+
let reconnectDelay = 1000;
|
|
443
|
+
function scheduleReconnect() {
|
|
444
|
+
const delay = reconnectDelay;
|
|
445
|
+
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
|
446
|
+
setTimeout(connect, delay);
|
|
447
|
+
}
|
|
448
|
+
function resetReconnectDelay() { reconnectDelay = 1000; }
|
|
449
|
+
|
|
450
|
+
connect();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function sendMessageToDaemon(text, previousMessages) {
|
|
454
|
+
const token = readToken();
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
const body = JSON.stringify({ text, previousMessages });
|
|
457
|
+
const options = {
|
|
458
|
+
hostname: DAEMON_HOST,
|
|
459
|
+
port: DAEMON_PORT,
|
|
460
|
+
path: "/overlay/message",
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: {
|
|
463
|
+
"Content-Type": "application/json",
|
|
464
|
+
"Content-Length": Buffer.byteLength(body),
|
|
465
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
const req = http.request(options, (res) => {
|
|
469
|
+
let data = "";
|
|
470
|
+
res.on("data", (c) => data += c);
|
|
471
|
+
res.on("end", () => {
|
|
472
|
+
try { resolve(JSON.parse(data)); }
|
|
473
|
+
catch { resolve({ ok: true }); }
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
req.on("error", reject);
|
|
477
|
+
req.write(body);
|
|
478
|
+
req.end();
|
|
479
|
+
});
|
|
480
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Preload — context bridge between Electron main and renderer.
|
|
2
|
+
"use strict";
|
|
3
|
+
const { contextBridge, ipcRenderer } = require("electron");
|
|
4
|
+
|
|
5
|
+
contextBridge.exposeInMainWorld("apx", {
|
|
6
|
+
// Recording control
|
|
7
|
+
onRecordingStart: (fn) => ipcRenderer.on("recording-start", fn),
|
|
8
|
+
onRecordingStop: (fn) => ipcRenderer.on("recording-stop", fn),
|
|
9
|
+
toggleRecording: () => ipcRenderer.invoke("toggle-recording"),
|
|
10
|
+
cancel: () => ipcRenderer.invoke("cancel"),
|
|
11
|
+
close: () => ipcRenderer.invoke("close-overlay"),
|
|
12
|
+
|
|
13
|
+
// Transcription
|
|
14
|
+
transcribeChunk: (buffer, format, language) =>
|
|
15
|
+
ipcRenderer.invoke("transcribe-chunk", { buffer, format, language }),
|
|
16
|
+
|
|
17
|
+
// Check if the whisper model is loaded (false = still loading)
|
|
18
|
+
checkWhisperReady: () => ipcRenderer.invoke("check-whisper-ready"),
|
|
19
|
+
|
|
20
|
+
// Send final text to daemon
|
|
21
|
+
sendMessage: (text, previousMessages) =>
|
|
22
|
+
ipcRenderer.invoke("send-message", { text, previousMessages }),
|
|
23
|
+
|
|
24
|
+
// Daemon events (tokens, tools, done, error)
|
|
25
|
+
onDaemonEvent: (fn) => ipcRenderer.on("daemon-event", (_e, msg) => fn(msg)),
|
|
26
|
+
onDaemonConnected: (fn) => ipcRenderer.on("daemon-connected", fn),
|
|
27
|
+
onDaemonDisconnected: (fn) => ipcRenderer.on("daemon-disconnected", fn),
|
|
28
|
+
|
|
29
|
+
// Platform info
|
|
30
|
+
platform: process.platform,
|
|
31
|
+
|
|
32
|
+
// Query configured shortcut for display in hint bar
|
|
33
|
+
getShortcut: () => ipcRenderer.invoke("get-shortcut"),
|
|
34
|
+
});
|