handmux 0.26.0 → 0.27.2
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/README.md +20 -93
- package/README.zh-CN.md +20 -93
- package/dist/package.json +19 -19
- package/dist/public/assets/index-B4pKf3IW.js +356 -0
- package/dist/public/assets/index-Cdrh2CUp.css +32 -0
- package/dist/public/assets/pcm-worklet-NzlBQ6-t.js +1 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +2 -0
- package/dist/src/agent-runtime/builtinRuntime.js +3 -1
- package/dist/src/agent-runtime/conversationActivation.js +136 -4
- package/dist/src/agent-runtime/run.js +23 -2
- package/dist/src/agent-runtime/runtime.js +174 -15
- package/dist/src/agent-runtime/tmuxRuntime.js +3 -7
- package/dist/src/agents/codex.js +78 -10
- package/dist/src/agents/codexActivationReceipt.js +149 -0
- package/dist/src/agents/codexConversation.js +59 -20
- package/dist/src/agents/codexConversationActivation.js +265 -65
- package/dist/src/agents/codexOpenSession.js +164 -0
- package/dist/src/agents/nativeInbox.js +24 -56
- package/dist/src/agents/scanUtils.js +7 -1
- package/dist/src/apiErrors.js +10 -1
- package/dist/src/asr/config.js +29 -0
- package/dist/src/asr/providerRegistry.js +19 -0
- package/dist/src/asr/providers/tencent.js +135 -0
- package/dist/src/asr/providers/xfyun.js +73 -0
- package/dist/src/asr/tencentSentence.js +111 -0
- package/dist/src/asr/tencentSign.js +20 -0
- package/dist/src/asr/verify.js +80 -0
- package/dist/src/cli/codexManaged.js +13 -2
- package/dist/src/cli/i18n/en.js +16 -1
- package/dist/src/cli/i18n/zh.js +16 -1
- package/dist/src/cli/options.js +29 -2
- package/dist/src/cli/setupModel.js +49 -5
- package/dist/src/cli/setupWizard.js +149 -29
- package/dist/src/cli/supervisor.js +24 -8
- package/dist/src/cli/supervisorLaunch.js +26 -0
- package/dist/src/codexAppServer.js +41 -4
- package/dist/src/routes/agents.js +51 -14
- package/dist/src/routes/system.js +109 -10
- package/dist/src/server.js +11 -2
- package/dist/src/terminalStream.js +8 -30
- package/dist/src/tmux/commands.js +14 -0
- package/dist/src/tmux/controlProtocol.js +26 -0
- package/dist/src/tmux/paneOutputCapture.js +180 -0
- package/package.json +5 -5
- package/dist/public/assets/index-BVFKs5dN.css +0 -32
- package/dist/public/assets/index-KDa5PCoi.js +0 -356
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
|
@@ -3,6 +3,15 @@ import path from 'node:path';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { spawn as spawnChild } from 'node:child_process';
|
|
5
5
|
const PANE_RE = /^%\d+$/;
|
|
6
|
+
function hasExplicitCwd(args) {
|
|
7
|
+
const optionsEnd = args.indexOf('--');
|
|
8
|
+
const options = optionsEnd === -1 ? args : args.slice(0, optionsEnd);
|
|
9
|
+
return options.some((arg) => arg === '-C' || arg === '--cd'
|
|
10
|
+
|| arg.startsWith('--cd=') || (arg.startsWith('-C') && arg.length > 2));
|
|
11
|
+
}
|
|
12
|
+
export function managedCodexArgs(args, cwd) {
|
|
13
|
+
return hasExplicitCwd(args) ? [...args] : ['--cd', cwd, ...args];
|
|
14
|
+
}
|
|
6
15
|
const errorCode = (error) => {
|
|
7
16
|
if (typeof error !== 'object' || error === null || !('code' in error))
|
|
8
17
|
return null;
|
|
@@ -41,7 +50,7 @@ function finish(child) {
|
|
|
41
50
|
}
|
|
42
51
|
// One App Server belongs to one tmux pane. The TUI and Handmux connect to that pane-owned Unix socket,
|
|
43
52
|
// so thread identity, messages, approvals, settings, interrupts and inbox state share one runtime.
|
|
44
|
-
export async function runManagedCodexProcess(args = [], { home = os.homedir(), env = process.env, spawn = spawnChild, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }), unlink = (file) => { try {
|
|
53
|
+
export async function runManagedCodexProcess(args = [], { home = os.homedir(), cwd = process.cwd(), env = process.env, spawn = spawnChild, mkdir = (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }), unlink = (file) => { try {
|
|
45
54
|
fs.unlinkSync(file);
|
|
46
55
|
}
|
|
47
56
|
catch (error) {
|
|
@@ -60,7 +69,9 @@ catch (error) {
|
|
|
60
69
|
let tui = null;
|
|
61
70
|
try {
|
|
62
71
|
await waitForSocket(socketPath, appServer, waitOptions);
|
|
63
|
-
tui = spawn('codex', [
|
|
72
|
+
tui = spawn('codex', [
|
|
73
|
+
'--remote', `unix://${socketPath}`, ...managedCodexArgs(args, cwd),
|
|
74
|
+
], {
|
|
64
75
|
env: managedEnv,
|
|
65
76
|
stdio: 'inherit',
|
|
66
77
|
});
|
package/dist/src/cli/i18n/en.js
CHANGED
|
@@ -273,11 +273,26 @@ export default {
|
|
|
273
273
|
'setup.pushOff': 'Turn off push',
|
|
274
274
|
'setup.voiceOff': 'Turn off voice',
|
|
275
275
|
'setup.voiceKeep': 'keep voice input configured?',
|
|
276
|
-
'setup.voiceSetup': 'Turn on voice input? (
|
|
276
|
+
'setup.voiceSetup': 'Turn on voice input? (live dictation or whole-sentence recognition)',
|
|
277
|
+
'setup.voiceProvider': 'Voice provider',
|
|
278
|
+
'setup.voiceTencent': 'Tencent Cloud real-time ASR',
|
|
279
|
+
'setup.voiceTencentHint': 'WebSocket 2.0 · live partials',
|
|
280
|
+
'setup.voiceTencentSentence': 'Tencent Cloud sentence ASR',
|
|
281
|
+
'setup.voiceTencentSentenceHint': 'Tap to record · returns after stopping',
|
|
282
|
+
'setup.voiceXfyun': 'iFlytek voice dictation',
|
|
283
|
+
'setup.voiceXfyunHint': 'live partials',
|
|
277
284
|
'setup.voiceAppId': 'xfyun appId',
|
|
278
285
|
'setup.voiceApiKey': 'xfyun apiKey',
|
|
279
286
|
'setup.voiceApiSecret': 'xfyun apiSecret',
|
|
287
|
+
'setup.voiceTencentAppId': 'Tencent Cloud appId',
|
|
288
|
+
'setup.voiceTencentSecretId': 'Tencent Cloud secretId',
|
|
289
|
+
'setup.voiceTencentSecretKey': 'Tencent Cloud secretKey',
|
|
290
|
+
'setup.voiceTencentEngine': 'engineModelType (16k PCM; default: 16k_zh)',
|
|
280
291
|
'setup.voiceSkipped': ' (skipped — missing fields)',
|
|
292
|
+
'setup.voiceVerifying': '→ Verifying {profile} with the provider…',
|
|
293
|
+
'setup.voiceVerified': '✓ Voice configuration verified',
|
|
294
|
+
'setup.voiceVerifyFailed': '✗ Voice verification failed: {msg}. Check the values or network, then edit a field to retry.',
|
|
295
|
+
'setup.voiceVerifyUnavailable': '⚠ This voice provider does not support automatic verification yet.',
|
|
281
296
|
'setup.cfLogin': '→ logging in to Cloudflare (a browser will open) …',
|
|
282
297
|
'setup.cfReuse': '✓ reusing existing tunnel {name} ({id})',
|
|
283
298
|
'setup.cfCredMissing1': '⚠ credentials file {file} not found on this machine — the tunnel was likely',
|
package/dist/src/cli/i18n/zh.js
CHANGED
|
@@ -272,11 +272,26 @@ export default {
|
|
|
272
272
|
'setup.pushOff': '关闭推送',
|
|
273
273
|
'setup.voiceOff': '关闭语音',
|
|
274
274
|
'setup.voiceKeep': '保留已配置的语音输入吗?',
|
|
275
|
-
'setup.voiceSetup': '开启语音输入吗?(
|
|
275
|
+
'setup.voiceSetup': '开启语音输入吗?(支持实时听写或整句识别)',
|
|
276
|
+
'setup.voiceProvider': '语音服务',
|
|
277
|
+
'setup.voiceTencent': '腾讯云实时语音识别',
|
|
278
|
+
'setup.voiceTencentHint': 'WebSocket 2.0 · 实时返回',
|
|
279
|
+
'setup.voiceTencentSentence': '腾讯云一句话识别',
|
|
280
|
+
'setup.voiceTencentSentenceHint': '点击录音 · 停止后整句返回',
|
|
281
|
+
'setup.voiceXfyun': '讯飞语音听写',
|
|
282
|
+
'setup.voiceXfyunHint': '实时返回',
|
|
276
283
|
'setup.voiceAppId': 'xfyun appId',
|
|
277
284
|
'setup.voiceApiKey': 'xfyun apiKey',
|
|
278
285
|
'setup.voiceApiSecret': 'xfyun apiSecret',
|
|
286
|
+
'setup.voiceTencentAppId': 'Tencent Cloud appId',
|
|
287
|
+
'setup.voiceTencentSecretId': 'Tencent Cloud secretId',
|
|
288
|
+
'setup.voiceTencentSecretKey': 'Tencent Cloud secretKey',
|
|
289
|
+
'setup.voiceTencentEngine': 'engineModelType(16k PCM,默认 16k_zh)',
|
|
279
290
|
'setup.voiceSkipped': ' (已跳过 —— 字段缺失)',
|
|
291
|
+
'setup.voiceVerifying': '→ 正在向服务商验证 {profile}…',
|
|
292
|
+
'setup.voiceVerified': '✓ 语音配置验证通过',
|
|
293
|
+
'setup.voiceVerifyFailed': '✗ 语音配置验证失败:{msg}。请检查填写内容或网络,修改任一字段后会重新验证。',
|
|
294
|
+
'setup.voiceVerifyUnavailable': '⚠ 当前语音服务商暂不支持自动验证。',
|
|
280
295
|
'setup.cfLogin': '→ 正在登录 Cloudflare(会打开一个浏览器)…',
|
|
281
296
|
'setup.cfReuse': '✓ 复用已有隧道 {name}({id})',
|
|
282
297
|
'setup.cfCredMissing1': '⚠ 本机找不到凭据文件 {file} —— 该隧道很可能是',
|
package/dist/src/cli/options.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
|
|
3
3
|
// (generated when unset) so a public tunnel can never come up token-less.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
5
6
|
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
6
7
|
export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'];
|
|
7
8
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -90,7 +91,8 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
|
|
|
90
91
|
uploadExts: optionalString(pick('uploadExts', env.HANDMUX_UPLOAD_EXTS), 'upload-exts'),
|
|
91
92
|
previewDomain: optionalString(pick('previewDomain', env.HANDMUX_PREVIEW_DOMAIN), 'preview-domain'),
|
|
92
93
|
vapid: parseVapid(fileCfg.vapid), // { public, private, subject } — push notifications
|
|
93
|
-
|
|
94
|
+
voice: parseVoice(fileCfg.voice, fileCfg.xfyun),
|
|
95
|
+
xfyun: parseXfyun(fileCfg.xfyun), // legacy voice input; parseVoice migrates it at runtime
|
|
94
96
|
shortcuts: normalizeShortcuts(fileCfg.shortcuts),
|
|
95
97
|
// An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
|
|
96
98
|
// own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
|
|
@@ -162,6 +164,25 @@ function parseVapid(value) {
|
|
|
162
164
|
function parseXfyun(value) {
|
|
163
165
|
return stringFields(value, ['appId', 'apiKey', 'apiSecret']);
|
|
164
166
|
}
|
|
167
|
+
function parseVoice(value, legacyXfyun) {
|
|
168
|
+
if (isRecord(value)) {
|
|
169
|
+
const adapter = voiceProviderRegistry.get(value.provider);
|
|
170
|
+
const providers = isRecord(value.providers) ? value.providers : {};
|
|
171
|
+
if (adapter) {
|
|
172
|
+
if (value.enabled === false)
|
|
173
|
+
return null;
|
|
174
|
+
const parsed = {};
|
|
175
|
+
for (const candidate of voiceProviderRegistry.adapters) {
|
|
176
|
+
const config = stringFields(providers[candidate.id], candidate.fields.map((field) => field.key));
|
|
177
|
+
if (config)
|
|
178
|
+
parsed[candidate.id] = config;
|
|
179
|
+
}
|
|
180
|
+
return { provider: adapter.id, mode: providerMode(adapter, value.mode), providers: parsed };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const xfyun = parseXfyun(legacyXfyun);
|
|
184
|
+
return xfyun ? { provider: 'xfyun', mode: 'streaming', providers: { xfyun } } : null;
|
|
185
|
+
}
|
|
165
186
|
function resolvePublicUrl(flags, fileCfg, env, tunnel) {
|
|
166
187
|
if (flags.publicUrl != null)
|
|
167
188
|
return flags.publicUrl;
|
|
@@ -240,6 +261,12 @@ export function explainConfig(flags = {}, fileCfg = {}, cfgPath = null, env = pr
|
|
|
240
261
|
}
|
|
241
262
|
// Integrations live only in the file (secrets); show presence, never the keys.
|
|
242
263
|
rows.push({ key: 'push (vapid)', origin: fileCfg.vapid ? (cfgPath || 'file') : 'default', display: fileCfg.vapid ? 'on' : 'off' });
|
|
243
|
-
|
|
264
|
+
const voice = parseVoice(fileCfg.voice, fileCfg.xfyun);
|
|
265
|
+
const voiceExplicitlyDisabled = isRecord(fileCfg.voice) && fileCfg.voice.enabled === false;
|
|
266
|
+
rows.push({
|
|
267
|
+
key: 'voice',
|
|
268
|
+
origin: voice || voiceExplicitlyDisabled ? (cfgPath || 'file') : 'default',
|
|
269
|
+
display: voice ? voice.provider : 'off',
|
|
270
|
+
});
|
|
244
271
|
return rows;
|
|
245
272
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// interactive shell (setupWizard.ts) so the tested, side-effect-free logic stands on its own. Depends only
|
|
4
4
|
// on i18n (t/getLocale) for user-facing strings — otherwise deterministic given its inputs.
|
|
5
5
|
import { t, getLocale } from './i18n/index.js';
|
|
6
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
6
7
|
import { TUNNELS } from './options.js';
|
|
7
8
|
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
9
|
const isTunnel = (value) => typeof value === 'string' && TUNNELS.includes(value);
|
|
@@ -16,6 +17,25 @@ const stringObject = (value, keys) => {
|
|
|
16
17
|
result[key] = value[key];
|
|
17
18
|
return result;
|
|
18
19
|
};
|
|
20
|
+
// Voice credentials and identifiers are copied from provider consoles, where an unnoticed leading or
|
|
21
|
+
// trailing space makes an otherwise valid signature fail. Normalize at the persistence boundary as well as
|
|
22
|
+
// in the interactive editor so every setup caller gets the same behavior.
|
|
23
|
+
export function normalizeVoiceConfig(voice) {
|
|
24
|
+
const providers = {};
|
|
25
|
+
for (const [provider, value] of Object.entries(voice.providers)) {
|
|
26
|
+
if (!value) {
|
|
27
|
+
providers[provider] = value;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const normalized = {};
|
|
31
|
+
for (const [key, fieldValue] of Object.entries(value)) {
|
|
32
|
+
if (typeof fieldValue === 'string')
|
|
33
|
+
normalized[key] = fieldValue.trim();
|
|
34
|
+
}
|
|
35
|
+
providers[provider] = normalized;
|
|
36
|
+
}
|
|
37
|
+
return { ...voice, providers };
|
|
38
|
+
}
|
|
19
39
|
// ~/.cloudflared/config.yml for a named tunnel: route the hostname to the local handmux port.
|
|
20
40
|
export function cfConfigYaml({ tunnelName, credentialsFile, hostname, port, }) {
|
|
21
41
|
return [
|
|
@@ -61,7 +81,7 @@ const WIZARD_KEYS = [
|
|
|
61
81
|
'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
|
|
62
82
|
'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
|
|
63
83
|
'authtoken', 'cpolarRegion',
|
|
64
|
-
'vapid', 'xfyun',
|
|
84
|
+
'vapid', 'voice', 'xfyun',
|
|
65
85
|
];
|
|
66
86
|
// The tunnel-specific keys, cleared whenever the tunnel changes so a switch never carries a stale field.
|
|
67
87
|
export const TUNNEL_KEYS = ['sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl', 'authtoken', 'cpolarRegion'];
|
|
@@ -98,8 +118,8 @@ export function configFromAnswers(a) {
|
|
|
98
118
|
}
|
|
99
119
|
if (a.vapid)
|
|
100
120
|
cfg.vapid = a.vapid;
|
|
101
|
-
if (a.
|
|
102
|
-
cfg.
|
|
121
|
+
if (a.voice)
|
|
122
|
+
cfg.voice = normalizeVoiceConfig(a.voice);
|
|
103
123
|
return cfg;
|
|
104
124
|
}
|
|
105
125
|
// Fold this run's answers into an existing config: preserve every non-wizard field, replace the wizard's
|
|
@@ -132,13 +152,37 @@ export function answersFromConfig(config = {}) {
|
|
|
132
152
|
if (typeof cfg.remotePort === 'number' && Number.isFinite(cfg.remotePort))
|
|
133
153
|
a.remotePort = cfg.remotePort;
|
|
134
154
|
const vapid = stringObject(cfg.vapid, ['public', 'private', 'subject']);
|
|
155
|
+
const voice = voiceFromConfig(cfg.voice);
|
|
135
156
|
const xfyun = stringObject(cfg.xfyun, ['appId', 'apiKey', 'apiSecret']);
|
|
136
157
|
if (vapid)
|
|
137
158
|
a.vapid = vapid;
|
|
138
|
-
if (
|
|
139
|
-
a.
|
|
159
|
+
if (voice)
|
|
160
|
+
a.voice = voice;
|
|
161
|
+
else if (xfyun)
|
|
162
|
+
a.voice = normalizeVoiceConfig({
|
|
163
|
+
provider: 'xfyun', mode: 'streaming', providers: { xfyun },
|
|
164
|
+
});
|
|
140
165
|
return a;
|
|
141
166
|
}
|
|
167
|
+
function voiceFromConfig(value) {
|
|
168
|
+
if (!isRecord(value) || !isRecord(value.providers))
|
|
169
|
+
return undefined;
|
|
170
|
+
const adapter = voiceProviderRegistry.get(value.provider);
|
|
171
|
+
if (!adapter)
|
|
172
|
+
return undefined;
|
|
173
|
+
const providers = {};
|
|
174
|
+
for (const candidate of voiceProviderRegistry.adapters) {
|
|
175
|
+
const config = stringObject(value.providers[candidate.id], candidate.fields.map((field) => field.key));
|
|
176
|
+
if (config)
|
|
177
|
+
providers[candidate.id] = config;
|
|
178
|
+
}
|
|
179
|
+
return normalizeVoiceConfig({
|
|
180
|
+
...(value.enabled === false ? { enabled: false } : {}),
|
|
181
|
+
provider: adapter.id,
|
|
182
|
+
mode: providerMode(adapter, value.mode),
|
|
183
|
+
providers,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
142
186
|
// The dim one-line summary shown next to the Connection row (and reused nowhere else). Pure.
|
|
143
187
|
export function summarizeConnection(a) {
|
|
144
188
|
const bare = (url) => String(url || '').replace(/^https?:\/\//, '');
|
|
@@ -8,6 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { spawnSync } from 'node:child_process';
|
|
10
10
|
import webpush from 'web-push';
|
|
11
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
11
12
|
import { configPath } from './state.js';
|
|
12
13
|
import { defaultGen as genToken, TUNNELS } from './options.js';
|
|
13
14
|
import { resolveCloudflared } from './cloudflared.js';
|
|
@@ -28,8 +29,8 @@ const isTunnel = (value) => (typeof value === 'string' && TUNNELS.includes(value
|
|
|
28
29
|
// The pure answer↔config model + validators + cloudflared text helpers live in setupModel.js. Imported for
|
|
29
30
|
// the interactive shell below; re-exported so their historical import path (this module) stays valid for
|
|
30
31
|
// callers and tests.
|
|
31
|
-
import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
|
|
32
|
-
export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
|
|
32
|
+
import { cfConfigYaml, parseTunnelCreate, findTunnelId, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, TUNNEL_KEYS, } from './setupModel.js';
|
|
33
|
+
export { cfConfigYaml, parseTunnelCreate, findTunnelId, configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection, normalizeVoiceConfig, validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken, } from './setupModel.js';
|
|
33
34
|
function readExisting(file) {
|
|
34
35
|
try {
|
|
35
36
|
return new PrivateStateStore(file).readStrict() || {};
|
|
@@ -83,7 +84,12 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
83
84
|
{ value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
|
|
84
85
|
{ value: 'browser', label: t('setup.secBrowser'), hint: a.previewDomain || t('setup.browserOff') },
|
|
85
86
|
{ value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
|
|
86
|
-
{
|
|
87
|
+
{
|
|
88
|
+
value: 'voice', label: t('setup.secVoice'),
|
|
89
|
+
hint: a.voice && a.voice.enabled !== false
|
|
90
|
+
? `${voiceProfileLabel(a.voice)} · ${a.voice.providers[a.voice.provider]?.appId || t('setup.on')}`
|
|
91
|
+
: t('setup.off'),
|
|
92
|
+
},
|
|
87
93
|
// A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
|
|
88
94
|
// sits at the bottom of the settings, just above the actions.
|
|
89
95
|
{ value: 'language', label: t('setup.secLanguage'), hint: a.lang === 'zh' ? '中文' : 'English' },
|
|
@@ -128,11 +134,11 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
128
134
|
delete a.vapid;
|
|
129
135
|
}
|
|
130
136
|
else if (choice === 'voice') {
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
a.
|
|
137
|
+
const voice = await editVoice(a);
|
|
138
|
+
if (voice)
|
|
139
|
+
a.voice = voice;
|
|
134
140
|
else
|
|
135
|
-
delete a.
|
|
141
|
+
delete a.voice;
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
144
|
catch (e) {
|
|
@@ -507,55 +513,84 @@ async function editPush(a) {
|
|
|
507
513
|
} // Esc in a sub-edit → back to the mini-hub
|
|
508
514
|
}
|
|
509
515
|
}
|
|
510
|
-
// Voice input
|
|
511
|
-
//
|
|
516
|
+
// Voice input provider hub. Credentials for multiple providers may coexist, but only the explicitly selected
|
|
517
|
+
// provider is injected into the server process. Secrets are masked and replaced, never revealed.
|
|
512
518
|
async function editVoice(a) {
|
|
513
|
-
let
|
|
514
|
-
if (!
|
|
519
|
+
let voice = a.voice;
|
|
520
|
+
if (!voice || voice.enabled === false) {
|
|
515
521
|
let on;
|
|
516
522
|
try {
|
|
517
523
|
on = await ask(confirm({ message: withBack(t('setup.voiceSetup')), initialValue: false, ...yesno() }));
|
|
518
524
|
}
|
|
519
525
|
catch (e) {
|
|
520
526
|
if (e === CANCELLED)
|
|
521
|
-
return
|
|
527
|
+
return voice;
|
|
522
528
|
throw e;
|
|
523
529
|
}
|
|
524
530
|
if (!on)
|
|
525
|
-
return
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
531
|
+
return voice;
|
|
532
|
+
if (voice) {
|
|
533
|
+
voice = await ensureVoiceProvider({ ...voice, enabled: true });
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
const profile = await chooseVoiceProfile({ provider: 'tencent', mode: 'streaming' });
|
|
537
|
+
voice = await ensureVoiceProvider({ ...profile, enabled: true, providers: {} });
|
|
538
|
+
}
|
|
539
|
+
await verifyVoiceConfiguration(voice);
|
|
530
540
|
}
|
|
531
541
|
for (;;) {
|
|
542
|
+
const provider = voice.provider;
|
|
543
|
+
const adapter = voiceProviderRegistry.get(provider);
|
|
544
|
+
if (!adapter)
|
|
545
|
+
throw new Error(`unknown voice provider: ${provider}`);
|
|
546
|
+
const current = voice.providers[provider] || {};
|
|
532
547
|
let pick;
|
|
533
548
|
try {
|
|
534
549
|
pick = await ask(select({
|
|
535
550
|
message: withBack(t('setup.secVoice')),
|
|
536
551
|
options: [
|
|
537
|
-
{ value: '
|
|
538
|
-
|
|
539
|
-
|
|
552
|
+
{ value: 'provider', label: t('setup.voiceProvider'), hint: voiceProfileLabel(voice) },
|
|
553
|
+
...adapter.fields.map((field) => ({
|
|
554
|
+
value: field.key,
|
|
555
|
+
label: field.key,
|
|
556
|
+
hint: field.secret ? maskSecret(current[field.key])
|
|
557
|
+
: current[field.key] || field.defaultValue || '',
|
|
558
|
+
})),
|
|
540
559
|
{ value: 'off', label: t('setup.voiceOff') },
|
|
541
560
|
],
|
|
542
|
-
initialValue: '
|
|
561
|
+
initialValue: 'provider',
|
|
543
562
|
}));
|
|
544
563
|
}
|
|
545
564
|
catch (e) {
|
|
546
565
|
if (e === CANCELLED)
|
|
547
|
-
return
|
|
566
|
+
return voice;
|
|
548
567
|
throw e;
|
|
549
568
|
}
|
|
550
569
|
if (pick === 'off')
|
|
551
|
-
return
|
|
570
|
+
return { ...voice, enabled: false };
|
|
552
571
|
try {
|
|
553
|
-
if (pick === '
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
572
|
+
if (pick === 'provider') {
|
|
573
|
+
const selected = await chooseVoiceProfile(voice);
|
|
574
|
+
voice = await ensureVoiceProvider({ ...voice, ...selected });
|
|
575
|
+
await verifyVoiceConfiguration(voice);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
const field = adapter.fields.find((candidate) => candidate.key === pick);
|
|
579
|
+
if (!field)
|
|
580
|
+
continue;
|
|
581
|
+
const config = { ...current };
|
|
582
|
+
config[field.key] = String(field.secret
|
|
583
|
+
? await ask(password({ message: t(field.promptKey), validate: validateNonEmpty(field.key) }))
|
|
584
|
+
: await ask(text({
|
|
585
|
+
message: t(field.promptKey),
|
|
586
|
+
initialValue: config[field.key] || field.defaultValue || '',
|
|
587
|
+
validate: validateNonEmpty(field.key),
|
|
588
|
+
}))).trim();
|
|
589
|
+
voice = normalizeVoiceConfig({
|
|
590
|
+
...voice, providers: { ...voice.providers, [provider]: config },
|
|
591
|
+
});
|
|
592
|
+
await verifyVoiceConfiguration(voice);
|
|
593
|
+
}
|
|
559
594
|
}
|
|
560
595
|
catch (e) {
|
|
561
596
|
if (e !== CANCELLED)
|
|
@@ -563,6 +598,91 @@ async function editVoice(a) {
|
|
|
563
598
|
}
|
|
564
599
|
}
|
|
565
600
|
}
|
|
601
|
+
function voiceProfileLabel(voice) {
|
|
602
|
+
const adapter = voiceProviderRegistry.get(voice.provider);
|
|
603
|
+
const profile = adapter?.profiles.find((candidate) => candidate.mode === voice.mode)
|
|
604
|
+
?? adapter?.profiles.find((candidate) => candidate.mode === adapter.defaultMode);
|
|
605
|
+
return profile ? t(profile.labelKey) : voice.provider;
|
|
606
|
+
}
|
|
607
|
+
async function chooseVoiceProfile(initial) {
|
|
608
|
+
const profiles = voiceProviderRegistry.adapters.flatMap((adapter) => (adapter.profiles.map((profile) => ({ adapter, profile, value: `${adapter.id}:${profile.mode}` }))));
|
|
609
|
+
const initialAdapter = voiceProviderRegistry.get(initial.provider) ?? voiceProviderRegistry.adapters[0];
|
|
610
|
+
if (!initialAdapter)
|
|
611
|
+
throw new Error('no voice providers registered');
|
|
612
|
+
const initialValue = `${initialAdapter.id}:${providerMode(initialAdapter, initial.mode)}`;
|
|
613
|
+
const selected = await ask(select({
|
|
614
|
+
message: t('setup.voiceProvider'),
|
|
615
|
+
options: profiles.map(({ profile, value }) => ({
|
|
616
|
+
value, label: t(profile.labelKey), hint: t(profile.hintKey),
|
|
617
|
+
})),
|
|
618
|
+
initialValue,
|
|
619
|
+
}));
|
|
620
|
+
const match = profiles.find((candidate) => candidate.value === selected);
|
|
621
|
+
return match ? { provider: match.adapter.id, mode: match.profile.mode }
|
|
622
|
+
: { provider: initialAdapter.id, mode: providerMode(initialAdapter, initial.mode) };
|
|
623
|
+
}
|
|
624
|
+
async function ensureVoiceProvider(voice) {
|
|
625
|
+
const adapter = voiceProviderRegistry.get(voice.provider);
|
|
626
|
+
if (!adapter)
|
|
627
|
+
throw new Error(`unknown voice provider: ${voice.provider}`);
|
|
628
|
+
const config = { ...voice.providers[adapter.id] };
|
|
629
|
+
for (const field of adapter.fields) {
|
|
630
|
+
const existing = config[field.key]?.trim();
|
|
631
|
+
if (existing) {
|
|
632
|
+
config[field.key] = existing;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
if (field.defaultValue) {
|
|
636
|
+
config[field.key] = field.defaultValue;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
config[field.key] = String(field.secret
|
|
640
|
+
? await ask(password({ message: t(field.promptKey), validate: validateNonEmpty(field.key) }))
|
|
641
|
+
: await ask(text({
|
|
642
|
+
message: t(field.promptKey), initialValue: field.defaultValue || '',
|
|
643
|
+
validate: validateNonEmpty(field.key),
|
|
644
|
+
}))).trim();
|
|
645
|
+
}
|
|
646
|
+
return normalizeVoiceConfig({
|
|
647
|
+
...voice, providers: { ...voice.providers, [adapter.id]: config },
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async function verifyVoiceConfiguration(voice) {
|
|
651
|
+
const adapter = voiceProviderRegistry.get(voice.provider);
|
|
652
|
+
if (!adapter)
|
|
653
|
+
throw new Error(`unknown voice provider: ${voice.provider}`);
|
|
654
|
+
if (!adapter.verify) {
|
|
655
|
+
note(t('setup.voiceVerifyUnavailable'));
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
const providerConfig = voice.providers[adapter.id] || {};
|
|
659
|
+
const env = {};
|
|
660
|
+
for (const field of adapter.fields)
|
|
661
|
+
env[field.env] = providerConfig[field.key] || '';
|
|
662
|
+
const config = adapter.readConfig(env, providerMode(adapter, voice.mode));
|
|
663
|
+
if (!adapter.isConfigured(config)) {
|
|
664
|
+
note(t('setup.voiceVerifyFailed', { msg: t('setup.voiceSkipped') }));
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
note(t('setup.voiceVerifying', { profile: voiceProfileLabel(voice) }));
|
|
668
|
+
try {
|
|
669
|
+
await adapter.verify(config);
|
|
670
|
+
note(t('setup.voiceVerified'));
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
catch (error) {
|
|
674
|
+
let message = errorMessage(error);
|
|
675
|
+
for (const field of adapter.fields) {
|
|
676
|
+
if (!field.secret)
|
|
677
|
+
continue;
|
|
678
|
+
const secret = providerConfig[field.key];
|
|
679
|
+
if (secret)
|
|
680
|
+
message = message.split(secret).join('••••');
|
|
681
|
+
}
|
|
682
|
+
note(t('setup.voiceVerifyFailed', { msg: message }));
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
566
686
|
// login (browser) → create → route dns → write config.yml. The only human step is the browser login.
|
|
567
687
|
async function provisionCloudflareNamed({ home, hostname, tunnelName, port, log, }) {
|
|
568
688
|
const bin = await resolveCloudflared(home);
|
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
|
7
7
|
import os from 'node:os';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
10
11
|
import { getDriver } from './drivers.js';
|
|
11
12
|
import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
12
13
|
import { initialSupervisorComponentState, reduceSupervisorComponent, } from './supervisorState.js';
|
|
@@ -114,7 +115,7 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
114
115
|
const startServer = () => {
|
|
115
116
|
// The server reads only process.env (no .env files) — the CLI resolved the one config file and we
|
|
116
117
|
// hand the server everything it needs here. This is the single injection point: config.json fields →
|
|
117
|
-
// the env names the server
|
|
118
|
+
// the env names the server reads. Voice injects only the selected provider's credentials.
|
|
118
119
|
const env = {
|
|
119
120
|
...processRef.env,
|
|
120
121
|
NODE_ENV: 'handmux',
|
|
@@ -142,13 +143,28 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
142
143
|
if (cfg.vapid.subject)
|
|
143
144
|
env.VAPID_SUBJECT = cfg.vapid.subject;
|
|
144
145
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
// Config is authoritative for voice. Always remove inherited ASR state first, including when setup
|
|
147
|
+
// explicitly disabled voice; otherwise stale shell/service credentials silently re-enable XFYUN.
|
|
148
|
+
for (const key of [
|
|
149
|
+
'HANDMUX_ASR_PROVIDER',
|
|
150
|
+
'HANDMUX_ASR_MODE',
|
|
151
|
+
...voiceProviderRegistry.adapters.flatMap((candidate) => (candidate.fields.map((field) => field.env))),
|
|
152
|
+
])
|
|
153
|
+
delete env[key];
|
|
154
|
+
const voice = cfg.voice === null || cfg.voice?.enabled === false ? null
|
|
155
|
+
: cfg.voice ?? (cfg.xfyun ? { provider: 'xfyun', providers: { xfyun: cfg.xfyun } } : null);
|
|
156
|
+
if (voice) {
|
|
157
|
+
const adapter = voiceProviderRegistry.get(voice.provider);
|
|
158
|
+
if (adapter) {
|
|
159
|
+
env.HANDMUX_ASR_PROVIDER = adapter.id;
|
|
160
|
+
env.HANDMUX_ASR_MODE = providerMode(adapter, voice.mode);
|
|
161
|
+
const provider = voice.providers[adapter.id];
|
|
162
|
+
for (const field of adapter.fields) {
|
|
163
|
+
const value = provider?.[field.key] || field.defaultValue;
|
|
164
|
+
if (value)
|
|
165
|
+
env[field.env] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
152
168
|
}
|
|
153
169
|
const c = spawnChild(processRef.execPath, [SERVER], { env, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
154
170
|
children.server = c;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { readSupervisorConfig, supervisorConfigPath, writeSupervisorConfig, } from './state.js';
|
|
3
|
+
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
3
4
|
import { TUNNELS } from './options.js';
|
|
4
5
|
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
5
6
|
const recordOf = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
@@ -43,6 +44,28 @@ const stringFields = (value, keys, key) => {
|
|
|
43
44
|
}
|
|
44
45
|
return result;
|
|
45
46
|
};
|
|
47
|
+
const voiceConfig = (value) => {
|
|
48
|
+
if (value === undefined || value === null)
|
|
49
|
+
return value;
|
|
50
|
+
const record = recordOf(value);
|
|
51
|
+
const providers = recordOf(record?.providers);
|
|
52
|
+
const adapter = voiceProviderRegistry.get(record?.provider);
|
|
53
|
+
if (!record || !adapter || !providers) {
|
|
54
|
+
throw new Error('invalid supervisor config: voice must select a known provider');
|
|
55
|
+
}
|
|
56
|
+
const parsed = {};
|
|
57
|
+
for (const candidate of voiceProviderRegistry.adapters) {
|
|
58
|
+
const config = stringFields(providers[candidate.id], candidate.fields.map((field) => field.key), `voice.providers.${candidate.id}`);
|
|
59
|
+
if (config)
|
|
60
|
+
parsed[candidate.id] = config;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
...(record.enabled === false ? { enabled: false } : {}),
|
|
64
|
+
provider: adapter.id,
|
|
65
|
+
mode: providerMode(adapter, record.mode),
|
|
66
|
+
providers: parsed,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
46
69
|
// The persisted file and the legacy base64 argv are untrusted process boundaries. Parse them before the
|
|
47
70
|
// supervisor starts so malformed or stale data fails as one explicit configuration error instead of being
|
|
48
71
|
// used as partially-typed process arguments.
|
|
@@ -87,6 +110,7 @@ export function parseSupervisorConfig(value) {
|
|
|
87
110
|
const cpolarBin = optionalString(record.cpolarBin, 'cpolarBin');
|
|
88
111
|
const cpolarRegion = optionalString(record.cpolarRegion, 'cpolarRegion');
|
|
89
112
|
const vapid = stringFields(record.vapid, ['public', 'private', 'subject'], 'vapid');
|
|
113
|
+
const voice = voiceConfig(record.voice);
|
|
90
114
|
const xfyun = stringFields(record.xfyun, ['appId', 'apiKey', 'apiSecret'], 'xfyun');
|
|
91
115
|
if (name !== undefined)
|
|
92
116
|
config.name = name;
|
|
@@ -120,6 +144,8 @@ export function parseSupervisorConfig(value) {
|
|
|
120
144
|
config.cpolarRegion = cpolarRegion;
|
|
121
145
|
if (vapid !== undefined)
|
|
122
146
|
config.vapid = vapid;
|
|
147
|
+
if (voice !== undefined)
|
|
148
|
+
config.voice = voice;
|
|
123
149
|
if (xfyun !== undefined)
|
|
124
150
|
config.xfyun = xfyun;
|
|
125
151
|
return config;
|