handmux 0.25.2 → 0.27.1
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/connectors/pi/index.js +16 -1
- package/dist/package.json +19 -19
- package/dist/public/assets/index-Cdrh2CUp.css +32 -0
- package/dist/public/assets/index-VT8s4ELL.js +356 -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 +5 -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/claudeConversation.js +2 -2
- package/dist/src/agents/codex.js +78 -10
- package/dist/src/agents/codexActivationReceipt.js +149 -0
- package/dist/src/agents/codexConversation.js +61 -22
- package/dist/src/agents/codexConversationActivation.js +265 -65
- package/dist/src/agents/codexConversationControls.js +4 -1
- package/dist/src/agents/codexOpenSession.js +164 -0
- package/dist/src/agents/conversationProjectionSafety.js +7 -0
- package/dist/src/agents/nativeInbox.js +24 -56
- package/dist/src/agents/pi.js +1 -0
- package/dist/src/agents/piConversationContext.js +49 -0
- 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/piExtension.js +11 -1
- 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 +2 -26
- 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-DH4DnhbV.js +0 -346
- package/dist/public/assets/index-DPBerEuM.css +0 -32
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import WebSocket from 'ws';
|
|
2
|
+
export class VoiceVerificationError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'VoiceVerificationError';
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const socketMessage = (data) => {
|
|
11
|
+
if (typeof data === 'string')
|
|
12
|
+
return data;
|
|
13
|
+
if (Buffer.isBuffer(data))
|
|
14
|
+
return data.toString('utf8');
|
|
15
|
+
if (data instanceof ArrayBuffer)
|
|
16
|
+
return Buffer.from(data).toString('utf8');
|
|
17
|
+
if (ArrayBuffer.isView(data)) {
|
|
18
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(data) && data.every(Buffer.isBuffer))
|
|
21
|
+
return Buffer.concat(data).toString('utf8');
|
|
22
|
+
return String(data);
|
|
23
|
+
};
|
|
24
|
+
// Opens the provider's real signed endpoint with a short, hard timeout. Provider adapters own the frames
|
|
25
|
+
// and response semantics; this helper owns only the shared socket lifecycle and never includes the signed URL
|
|
26
|
+
// in an error, so credentials and signed query material cannot leak into setup output.
|
|
27
|
+
export function verifyStreamingConnection(verification, { socketFactory = (url) => new WebSocket(url), timeoutMs = 8_000 } = {}) {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
let settled = false;
|
|
30
|
+
let socket;
|
|
31
|
+
const finish = (error) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
try {
|
|
37
|
+
socket?.terminate();
|
|
38
|
+
}
|
|
39
|
+
catch { /* already closed */ }
|
|
40
|
+
if (error)
|
|
41
|
+
reject(error);
|
|
42
|
+
else
|
|
43
|
+
resolve();
|
|
44
|
+
};
|
|
45
|
+
const timer = setTimeout(() => finish(new VoiceVerificationError('verification_timeout', 'provider verification timed out')), timeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
socket = socketFactory(verification.url);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
finish(new VoiceVerificationError('connection_failed', 'could not open provider connection'));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
socket.on('open', () => {
|
|
54
|
+
try {
|
|
55
|
+
verification.sendOnOpen?.(socket);
|
|
56
|
+
if (verification.successOnOpen)
|
|
57
|
+
finish();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
finish(new VoiceVerificationError('probe_failed', 'could not send provider verification data'));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
socket.on('message', (data) => {
|
|
64
|
+
try {
|
|
65
|
+
if (verification.acceptMessage?.(socketMessage(data)))
|
|
66
|
+
finish();
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
finish(error instanceof Error ? error
|
|
70
|
+
: new VoiceVerificationError('provider_rejected', 'provider rejected the configuration'));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
socket.on('unexpected-response', (_request, response) => finish(new VoiceVerificationError('handshake_rejected', `provider rejected the WebSocket handshake${response.statusCode ? ` (HTTP ${response.statusCode})` : ''}`)));
|
|
74
|
+
socket.on('error', () => finish(new VoiceVerificationError('connection_failed', 'could not connect to the provider')));
|
|
75
|
+
socket.on('close', (code) => finish(new VoiceVerificationError('connection_closed', `provider closed the verification connection (code ${code})`)));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// One second of 16 kHz mono signed 16-bit PCM. It contains no user audio and forms a valid request body
|
|
79
|
+
// while keeping validation cost and transfer size minimal.
|
|
80
|
+
export const silentPcmProbe = () => new Uint8Array(16_000 * 2);
|
|
@@ -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
|
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
6
|
const MANAGED_MARK = '// handmux-managed-pi-extension:v1';
|
|
6
7
|
const ENTRY_MARK = '// handmux-entry:';
|
|
8
|
+
const NATIVE_IMPORT_MODULE_URL = 'data:text/javascript;base64,'
|
|
9
|
+
+ 'ZXhwb3J0IGZ1bmN0aW9uIGltcG9ydFBpQ29ubmVjdG9yKHNwZWNpZmllcikgeyByZXR1cm4gaW1wb3J0KHNwZWNpZmllcik7IH0K';
|
|
7
10
|
export class PiExtensionInstallError extends Error {
|
|
8
11
|
code;
|
|
9
12
|
constructor(code, message) {
|
|
@@ -59,11 +62,18 @@ function expectedEntryUrl(entryFile) {
|
|
|
59
62
|
}
|
|
60
63
|
function wrapper(entryFile) {
|
|
61
64
|
const entryUrl = expectedEntryUrl(entryFile);
|
|
65
|
+
const fingerprint = createHash('sha256').update(fs.readFileSync(entryFile)).digest('hex');
|
|
66
|
+
const importUrl = `${entryUrl}?handmux=${fingerprint}`;
|
|
62
67
|
return [
|
|
63
68
|
MANAGED_MARK,
|
|
64
69
|
`${ENTRY_MARK}${entryUrl}`,
|
|
65
70
|
'// This tiny wrapper is owned by Handmux. Pi loads it from its documented global extension path.',
|
|
66
|
-
|
|
71
|
+
'// Native ESM import preserves the fingerprint that Pi\'s jiti strips from static re-exports.',
|
|
72
|
+
`import { importPiConnector } from ${JSON.stringify(NATIVE_IMPORT_MODULE_URL)};`,
|
|
73
|
+
'export default async function handmux(api) {',
|
|
74
|
+
` const connector = await importPiConnector(${JSON.stringify(importUrl)});`,
|
|
75
|
+
' return connector.default(api);',
|
|
76
|
+
'}',
|
|
67
77
|
'',
|
|
68
78
|
].join('\n');
|
|
69
79
|
}
|
|
@@ -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);
|