nova-audio-agent 0.2.2 → 0.2.3
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 +9 -12
- package/package.json +1 -1
- package/release-assets.json +3 -3
- package/src/capability-registry.mjs +52 -23
- package/src/command.mjs +20 -4
- package/src/key-probe.mjs +29 -0
- package/src/runtime.mjs +54 -2
- package/src/target.mjs +1 -1
package/README.md
CHANGED
|
@@ -37,16 +37,11 @@ npm install --global nova-audio-agent
|
|
|
37
37
|
|
|
38
38
|
## Configure
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Run `novaaudio`. The first launch downloads the app and opens a setup window
|
|
41
|
+
that asks for one key and tests it before you start:
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
Then add the keys for the services you want:
|
|
47
|
-
|
|
48
|
-
- **[DashScope](https://platform.qianwenai.com)** — the default Qwen realtime voice service. Required.
|
|
49
|
-
- **[Tavily](https://docs.tavily.com)** — web search. Optional.
|
|
43
|
+
- **[DashScope](https://platform.qianwenai.com)** — the default Qwen realtime voice service, plus memory, the camera and web search. Required.
|
|
44
|
+
- **[Tavily](https://docs.tavily.com)** — web search through Tavily instead of Bailian. Optional.
|
|
50
45
|
- **Codex** — a logged-in Codex executable, for coding tasks. Optional. See the [setup guide](https://deepnovacore.github.io/NovaAudioAgent/en/docs/getting-started).
|
|
51
46
|
|
|
52
47
|
Allow microphone access when prompted.
|
|
@@ -72,7 +67,8 @@ Hover over the desktop orb to reach its controls.
|
|
|
72
67
|
| --- | --- |
|
|
73
68
|
| `novaaudio` or `novaaudio start` | Download the desktop app if needed, then launch it |
|
|
74
69
|
| `novaaudio config` | Download the app if needed, then open its settings |
|
|
75
|
-
| `novaaudio doctor` | Inspect platform support, local installation, and
|
|
70
|
+
| `novaaudio doctor` | Inspect platform support, local installation, and which keys the voice pipeline needs |
|
|
71
|
+
| `novaaudio doctor --online` | Also test keys set in the environment against their provider |
|
|
76
72
|
| `novaaudio --version` | Print the desktop release version used by the CLI |
|
|
77
73
|
| `novaaudio --help` | Show command help |
|
|
78
74
|
|
|
@@ -84,8 +80,9 @@ The CLI downloads the matching desktop release from
|
|
|
84
80
|
before launching it. It reuses the desktop client's encrypted settings store and
|
|
85
81
|
never reads or prints secret values.
|
|
86
82
|
|
|
87
|
-
Run `novaaudio doctor` to see which release is installed and
|
|
88
|
-
|
|
83
|
+
Run `novaaudio doctor` to see which release is installed and which voice keys
|
|
84
|
+
are still missing. `novaaudio config` opens the full settings for the other
|
|
85
|
+
features.
|
|
89
86
|
|
|
90
87
|
For headless Ubuntu 22.04+, install `nova-audio-agent-server` and use `novaaudio-server start`; `novaaudio-server pair wss://your-host.ts.net` shows a terminal pairing QR after configuring the service.
|
|
91
88
|
|
package/package.json
CHANGED
package/release-assets.json
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
"repository": "deepnovacore/NovaAudioAgent",
|
|
4
4
|
"targets": {
|
|
5
5
|
"darwin-arm64": {
|
|
6
|
-
"artifact": "nova-audio-agent-0.2.
|
|
6
|
+
"artifact": "nova-audio-agent-0.2.3-macos-arm64-app.zip",
|
|
7
7
|
"executable": "Nova Audio Agent Desktop.app/Contents/MacOS/Nova Audio Agent Desktop",
|
|
8
8
|
"archive": "zip"
|
|
9
9
|
},
|
|
10
10
|
"win32-x64": {
|
|
11
|
-
"artifact": "nova-audio-agent-0.2.
|
|
11
|
+
"artifact": "nova-audio-agent-0.2.3-windows-x64-portable.zip",
|
|
12
12
|
"executable": "Nova Audio Agent Desktop.exe",
|
|
13
13
|
"archive": "zip"
|
|
14
14
|
},
|
|
15
15
|
"linux-x64": {
|
|
16
|
-
"artifact": "nova-audio-agent-0.2.
|
|
16
|
+
"artifact": "nova-audio-agent-0.2.3-linux-x64.AppImage",
|
|
17
17
|
"executable": "nova-audio-agent.AppImage",
|
|
18
18
|
"archive": "file"
|
|
19
19
|
}
|
|
@@ -80,6 +80,15 @@ export function interpolateCapabilityValue(value, environment) {
|
|
|
80
80
|
invalid('interpolated_value_too_large');
|
|
81
81
|
return resolved;
|
|
82
82
|
}
|
|
83
|
+
function missingEnvironment(values, environment) {
|
|
84
|
+
for (const value of values) {
|
|
85
|
+
for (const [, name = ''] of value.matchAll(/\$\{([^}]+)\}/gu)) {
|
|
86
|
+
if (ENV_NAME.test(name) && !environment[name]?.trim())
|
|
87
|
+
return name;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
83
92
|
function interpolateMap(value, environment) {
|
|
84
93
|
return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, interpolateCapabilityValue(val, environment)]));
|
|
85
94
|
}
|
|
@@ -110,39 +119,60 @@ export function parseCapabilityRegistry(input, environment = {}) {
|
|
|
110
119
|
const camera = moduleConfig(modules.camera, 'modules.camera', []);
|
|
111
120
|
const coding = moduleConfig(modules.coding, 'modules.coding', []);
|
|
112
121
|
const knowledge = moduleConfig(modules.knowledge, 'modules.knowledge', ['exposeToCodex']);
|
|
113
|
-
const
|
|
122
|
+
const requestedEnabled = bool(search.enabled, true, 'modules.search.enabled');
|
|
114
123
|
const configuredProvider = omittedDefault(search.provider, 'tavily');
|
|
115
124
|
if (configuredProvider !== 'tavily' && configuredProvider !== 'mcp')
|
|
116
125
|
invalid('modules.search.provider');
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
invalid('
|
|
126
|
+
const requestedProvider = environmentOverride(environment, 'SEARCH_PROVIDER') ?? configuredProvider;
|
|
127
|
+
if (requestedProvider !== 'tavily' && requestedProvider !== 'mcp')
|
|
128
|
+
invalid('SEARCH_PROVIDER');
|
|
120
129
|
const overrides = [];
|
|
121
|
-
if (environment.
|
|
122
|
-
overrides.push('
|
|
130
|
+
if (environment.SEARCH_PROVIDER?.trim())
|
|
131
|
+
overrides.push('SEARCH_PROVIDER');
|
|
123
132
|
let cameraEnabled = bool(camera.enabled, true, 'modules.camera.enabled');
|
|
124
|
-
const cameraOverride = environment.
|
|
133
|
+
const cameraOverride = environment.CAMERA_MODULE_ENABLED?.trim().toLowerCase();
|
|
125
134
|
if (cameraOverride) {
|
|
126
135
|
if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(cameraOverride))
|
|
127
|
-
invalid('
|
|
136
|
+
invalid('CAMERA_MODULE_ENABLED');
|
|
128
137
|
cameraEnabled = ['true', '1', 'yes', 'on'].includes(cameraOverride);
|
|
129
|
-
overrides.push('
|
|
138
|
+
overrides.push('CAMERA_MODULE_ENABLED');
|
|
139
|
+
}
|
|
140
|
+
let codingEnabled = bool(coding.enabled, true, 'modules.coding.enabled');
|
|
141
|
+
const codingOverride = environment.CODING_MODULE_ENABLED?.trim().toLowerCase();
|
|
142
|
+
if (codingOverride) {
|
|
143
|
+
if (!['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(codingOverride))
|
|
144
|
+
invalid('CODING_MODULE_ENABLED');
|
|
145
|
+
codingEnabled = ['true', '1', 'yes', 'on'].includes(codingOverride);
|
|
146
|
+
overrides.push('CODING_MODULE_ENABLED');
|
|
130
147
|
}
|
|
131
148
|
const tavily = object(omittedDefault(search.tavily, {}), 'modules.search.tavily', ['apiKeyEnv']);
|
|
132
149
|
const apiKeyEnv = string(omittedDefault(tavily.apiKeyEnv, 'TAVILY_API_KEY'), 'modules.search.tavily.apiKeyEnv', 128);
|
|
133
150
|
if (!ENV_NAME.test(apiKeyEnv))
|
|
134
151
|
invalid('modules.search.tavily.apiKeyEnv');
|
|
152
|
+
// Search is optional: a missing Tavily key reuses the DashScope key through the Bailian preset, or turns search off.
|
|
153
|
+
const tavilyUnavailable = requestedEnabled && requestedProvider === 'tavily' && !environment[apiKeyEnv]?.trim();
|
|
154
|
+
const fallback = tavilyUnavailable && search.mcp === undefined && !environmentOverride(environment, 'SEARCH_MCP_URL')
|
|
155
|
+
&& Boolean(environment.DASHSCOPE_API_KEY?.trim()) ? 'bailian_mcp' : undefined;
|
|
156
|
+
let enabled = requestedEnabled && (!tavilyUnavailable || fallback !== undefined);
|
|
157
|
+
const provider = fallback === undefined ? requestedProvider : 'mcp';
|
|
158
|
+
let reason = tavilyUnavailable ? `missing_environment:${apiKeyEnv}` : undefined;
|
|
135
159
|
let mcp;
|
|
136
160
|
if (search.mcp !== undefined || (enabled && provider === 'mcp')) {
|
|
137
161
|
const config = object(omittedDefault(search.mcp, {}), 'modules.search.mcp', ['url', 'tool', 'headers', 'timeoutMs', 'maxResultBytes']);
|
|
138
|
-
const urlOverride = environmentOverride(environment, '
|
|
139
|
-
const toolOverride = environmentOverride(environment, '
|
|
162
|
+
const urlOverride = environmentOverride(environment, 'SEARCH_MCP_URL');
|
|
163
|
+
const toolOverride = environmentOverride(environment, 'SEARCH_MCP_TOOL');
|
|
140
164
|
const preset = !urlOverride && config.url === undefined;
|
|
141
165
|
const configuredUrl = string(omittedDefault(config.url, BAILIAN_SEARCH_MCP_URL), 'modules.search.mcp.url');
|
|
142
166
|
const rawUrl = string(urlOverride ?? configuredUrl, 'modules.search.mcp.url');
|
|
143
167
|
const configuredTool = string(omittedDefault(config.tool, preset ? BAILIAN_SEARCH_MCP_TOOL : 'web_search'), 'modules.search.mcp.tool', 256);
|
|
144
168
|
const tool = string(toolOverride ?? configuredTool, 'modules.search.mcp.tool', 256);
|
|
145
169
|
const rawHeaders = stringMap(omittedDefault(config.headers, preset ? { authorization: 'Bearer ${DASHSCOPE_API_KEY}' } : {}), 'modules.search.mcp.headers');
|
|
170
|
+
// A search endpoint whose key is not set turns search off, like a missing Tavily key.
|
|
171
|
+
const missing = enabled && provider === 'mcp' ? missingEnvironment([rawUrl, ...Object.values(rawHeaders)], environment) : undefined;
|
|
172
|
+
if (missing !== undefined) {
|
|
173
|
+
enabled = false;
|
|
174
|
+
reason = `missing_environment:${missing}`;
|
|
175
|
+
}
|
|
146
176
|
const headers = enabled && provider === 'mcp' ? interpolateMap(rawHeaders, environment) : rawHeaders;
|
|
147
177
|
const url = enabled && provider === 'mcp' ? interpolateCapabilityValue(rawUrl, environment) : rawUrl;
|
|
148
178
|
if (enabled && provider === 'mcp')
|
|
@@ -151,9 +181,9 @@ export function parseCapabilityRegistry(input, environment = {}) {
|
|
|
151
181
|
timeoutMs: integer(config.timeoutMs, 8000, 60000, 'modules.search.mcp.timeoutMs'),
|
|
152
182
|
maxResultBytes: integer(config.maxResultBytes, 262144, 1048576, 'modules.search.mcp.maxResultBytes') };
|
|
153
183
|
if (urlOverride)
|
|
154
|
-
overrides.push('
|
|
184
|
+
overrides.push('SEARCH_MCP_URL');
|
|
155
185
|
if (toolOverride)
|
|
156
|
-
overrides.push('
|
|
186
|
+
overrides.push('SEARCH_MCP_TOOL');
|
|
157
187
|
}
|
|
158
188
|
const servers = object(omittedDefault(document.mcpServers, {}), 'mcpServers');
|
|
159
189
|
if (Object.keys(servers).length > 8)
|
|
@@ -176,8 +206,9 @@ export function parseCapabilityRegistry(input, environment = {}) {
|
|
|
176
206
|
}
|
|
177
207
|
}
|
|
178
208
|
return { version: 1, modules: {
|
|
179
|
-
search: { enabled, provider, tavily: { apiKeyEnv, ...(environment[apiKeyEnv] === undefined ? {} : { apiKey: environment[apiKeyEnv] }) }, ...(mcp === undefined ? {} : { mcp })
|
|
180
|
-
|
|
209
|
+
search: { enabled, provider, tavily: { apiKeyEnv, ...(environment[apiKeyEnv] === undefined ? {} : { apiKey: environment[apiKeyEnv] }) }, ...(mcp === undefined ? {} : { mcp }),
|
|
210
|
+
...(fallback === undefined ? {} : { fallback }), ...(reason === undefined ? {} : { reason }) },
|
|
211
|
+
camera: { enabled: cameraEnabled }, coding: { enabled: codingEnabled },
|
|
181
212
|
knowledge: { enabled: bool(knowledge.enabled, false, 'modules.knowledge.enabled'), exposeToCodex: bool(knowledge.exposeToCodex, false, 'modules.knowledge.exposeToCodex') },
|
|
182
213
|
}, mcpServers, serverStatuses, overrides,
|
|
183
214
|
frontbrainToolBudget: integer(document.frontbrainToolBudget, DEFAULT_FRONTBRAIN_TOOL_BUDGET, 256, 'frontbrainToolBudget') };
|
|
@@ -225,7 +256,7 @@ function parseServer(value, environment) {
|
|
|
225
256
|
}
|
|
226
257
|
export function loadCapabilityRegistry(options = {}) {
|
|
227
258
|
const environment = options.environment ?? process.env;
|
|
228
|
-
const explicitPath = (options.path === '' ? undefined : options.path) ?? environmentOverride(environment, '
|
|
259
|
+
const explicitPath = (options.path === '' ? undefined : options.path) ?? environmentOverride(environment, 'CAPABILITIES_CONFIG');
|
|
229
260
|
const path = explicitPath ?? DEFAULT_CAPABILITIES_PATH;
|
|
230
261
|
const explicit = explicitPath !== undefined;
|
|
231
262
|
const resolved = path.startsWith('~/') ? join(options.home ?? homedir(), path.slice(2)) : path;
|
|
@@ -246,19 +277,17 @@ export function loadCapabilityRegistry(options = {}) {
|
|
|
246
277
|
}
|
|
247
278
|
export function capabilityStatus(registry, toolCount = null) {
|
|
248
279
|
return { modules: {
|
|
249
|
-
search: { enabled: registry.modules.search.enabled, provider: registry.modules.search.provider
|
|
280
|
+
search: { enabled: registry.modules.search.enabled, provider: registry.modules.search.provider,
|
|
281
|
+
...(registry.modules.search.fallback === undefined ? {} : { fallback: registry.modules.search.fallback }),
|
|
282
|
+
...(registry.modules.search.reason === undefined ? {} : { reason: registry.modules.search.reason }) },
|
|
250
283
|
camera: registry.modules.camera, coding: registry.modules.coding, knowledge: registry.modules.knowledge,
|
|
251
284
|
}, servers: registry.serverStatuses, overrides: registry.overrides, toolCount, toolBudget: registry.frontbrainToolBudget };
|
|
252
285
|
}
|
|
253
286
|
export function inspectCapabilities(options = {}) {
|
|
254
287
|
try {
|
|
255
288
|
const registry = loadCapabilityRegistry(options);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
&& !environment[registry.modules.search.tavily.apiKeyEnv]?.trim();
|
|
259
|
-
return { ok: !missingTavily && registry.serverStatuses.every(server => server.status !== 'failed'),
|
|
260
|
-
...capabilityStatus(registry),
|
|
261
|
-
...(missingTavily ? { reason: `missing_environment:${registry.modules.search.tavily.apiKeyEnv}` } : {}) };
|
|
289
|
+
// A switched or disabled search is a reported degradation, not a failed configuration.
|
|
290
|
+
return { ok: registry.serverStatuses.every(server => server.status !== 'failed'), ...capabilityStatus(registry) };
|
|
262
291
|
}
|
|
263
292
|
catch (error) {
|
|
264
293
|
return { ok: false, reason: error instanceof CapabilityConfigurationError ? error.reason : 'invalid_configuration' };
|
package/src/command.mjs
CHANGED
|
@@ -6,7 +6,12 @@ export const HELP_TEXT = `Usage: novaaudio [start|config|doctor|--version|--help
|
|
|
6
6
|
novaaudio Install if needed and launch Nova Audio Agent
|
|
7
7
|
novaaudio start Install if needed and launch Nova Audio Agent
|
|
8
8
|
novaaudio config Open the desktop settings window
|
|
9
|
-
novaaudio doctor Inspect local installation and configuration status
|
|
9
|
+
novaaudio doctor Inspect local installation and configuration status
|
|
10
|
+
novaaudio doctor --online
|
|
11
|
+
Also test voice keys set in the environment against their provider`
|
|
12
|
+
|
|
13
|
+
const VOICE_KEY_SOURCES = Object.freeze({settings: 'saved in settings', environment: 'set in environment', missing: 'missing'})
|
|
14
|
+
const VOICE_KEY_PROBES = Object.freeze({ok: 'valid', rejected: 'rejected by provider', network: 'could not reach provider', deferred: 'checked on first connection'})
|
|
10
15
|
|
|
11
16
|
export async function main(argv, {
|
|
12
17
|
stdout = process.stdout,
|
|
@@ -15,7 +20,8 @@ export async function main(argv, {
|
|
|
15
20
|
doctor = inspectDoctor,
|
|
16
21
|
} = {}) {
|
|
17
22
|
const command = argv[0] ?? 'start'
|
|
18
|
-
|
|
23
|
+
const online = command === 'doctor' && argv.length === 2 && argv[1] === '--online'
|
|
24
|
+
if (argv.length > 1 && !online) {
|
|
19
25
|
stdout.write(`${HELP_TEXT}\n`)
|
|
20
26
|
return 2
|
|
21
27
|
}
|
|
@@ -28,7 +34,7 @@ export async function main(argv, {
|
|
|
28
34
|
return 0
|
|
29
35
|
}
|
|
30
36
|
if (command === 'doctor') {
|
|
31
|
-
const report = await doctor()
|
|
37
|
+
const report = await doctor({online})
|
|
32
38
|
stdout.write(`Nova Audio Agent ${PRODUCT_VERSION}\n`)
|
|
33
39
|
stdout.write(`Platform: ${report.platform} (${report.supported ? 'supported' : 'unsupported'})\n`)
|
|
34
40
|
if (report.supported) {
|
|
@@ -37,6 +43,16 @@ export async function main(argv, {
|
|
|
37
43
|
stdout.write(`Configured keys: ${report.configuredSecretKeys.length === 0 ? 'none' : report.configuredSecretKeys.join(', ')}\n`)
|
|
38
44
|
stdout.write(`Codex: ${report.codexPresent ? 'found' : 'missing'}\n`)
|
|
39
45
|
}
|
|
46
|
+
const missingVoiceKeys = report.voice?.keys.filter(key => key.source === null) ?? []
|
|
47
|
+
const rejectedVoiceKeys = report.voice?.keys.filter(key => key.probe === 'rejected') ?? []
|
|
48
|
+
if (report.voice) {
|
|
49
|
+
stdout.write(`Voice pipeline: ${report.voice.pipeline}\n`)
|
|
50
|
+
for (const key of report.voice.keys) stdout.write(` ${key.name}: ${VOICE_KEY_SOURCES[key.source ?? 'missing']}${key.probe ? ` (${VOICE_KEY_PROBES[key.probe] ?? key.probe})` : ''}\n`)
|
|
51
|
+
if (online && report.voice.keys.some(key => key.source === 'settings')) stdout.write(' Saved keys are encrypted; test them in the desktop setup window.\n')
|
|
52
|
+
if (missingVoiceKeys.length > 0) stdout.write(report.voice.pipeline === 'integrated'
|
|
53
|
+
? 'First run: start `novaaudio` and paste a DashScope API Key into the setup window that opens.\n'
|
|
54
|
+
: 'First run: start `novaaudio` and fill in the missing keys in the setup window that opens.\n')
|
|
55
|
+
}
|
|
40
56
|
if (report.capabilities !== undefined) {
|
|
41
57
|
const status = report.capabilities
|
|
42
58
|
stdout.write(`Capabilities: ${status.ok ? 'valid' : 'needs attention'}${status.reason ? ` (${status.reason})` : ''}\n`)
|
|
@@ -47,7 +63,7 @@ export async function main(argv, {
|
|
|
47
63
|
for (const name of status.overrides) stdout.write(` Override: ${name}\n`)
|
|
48
64
|
}
|
|
49
65
|
}
|
|
50
|
-
return report.supported && report.capabilities?.ok !== false ? 0 : 1
|
|
66
|
+
return report.supported && report.capabilities?.ok !== false && missingVoiceKeys.length === 0 && rejectedVoiceKeys.length === 0 ? 0 : 1
|
|
51
67
|
}
|
|
52
68
|
if (command !== 'start' && command !== 'config') {
|
|
53
69
|
stdout.write(`${HELP_TEXT}\n`)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Generated from clients/desktop/src/main/key-probe.mjs; run node runtime/scripts/check-capabilities.mjs --write.
|
|
2
|
+
// Live check for the one or two keys a first run needs. Each probe is a cheap
|
|
3
|
+
// authenticated model listing; the key never appears in the result.
|
|
4
|
+
const PROBES = Object.freeze({
|
|
5
|
+
dashscopeApiKey: 'https://dashscope.aliyuncs.com/compatible-mode/v1/models',
|
|
6
|
+
deepseekApiKey: 'https://api.deepseek.com/models',
|
|
7
|
+
arkApiKey: 'https://ark.cn-beijing.volces.com/api/v3/models',
|
|
8
|
+
})
|
|
9
|
+
// The Volcengine speech key has no cheap authenticated read; it is checked by the first connection.
|
|
10
|
+
const DEFERRED = new Set(['doubaoBigmodelApiKey'])
|
|
11
|
+
const PROBE_TIMEOUT_MS = 8_000
|
|
12
|
+
|
|
13
|
+
export async function probeApiKey(key, value, {fetch = globalThis.fetch, timeoutMs = PROBE_TIMEOUT_MS} = {}) {
|
|
14
|
+
if (typeof value !== 'string' || value.trim() === '' || /[\u0000-\u001f\u007f]/u.test(value)) return Object.freeze({status: 'missing'})
|
|
15
|
+
if (DEFERRED.has(key)) return Object.freeze({status: 'deferred'})
|
|
16
|
+
if (!Object.hasOwn(PROBES, key)) return Object.freeze({status: 'unsupported'})
|
|
17
|
+
try {
|
|
18
|
+
const response = await fetch(PROBES[key], {
|
|
19
|
+
headers: {authorization: `Bearer ${value.trim()}`},
|
|
20
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
21
|
+
})
|
|
22
|
+
await response.body?.cancel().catch(() => {})
|
|
23
|
+
if (response.ok) return Object.freeze({status: 'ok'})
|
|
24
|
+
if (response.status === 401 || response.status === 403) return Object.freeze({status: 'rejected', httpStatus: response.status})
|
|
25
|
+
return Object.freeze({status: 'network', httpStatus: response.status})
|
|
26
|
+
} catch {
|
|
27
|
+
return Object.freeze({status: 'network'})
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/runtime.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {inspectCapabilities} from './capability-registry.mjs'
|
|
2
|
+
import {probeApiKey} from './key-probe.mjs'
|
|
2
3
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
3
4
|
import { spawn, spawnSync } from 'node:child_process'
|
|
4
5
|
import {
|
|
@@ -415,11 +416,60 @@ function findCodex(platform = process.platform) {
|
|
|
415
416
|
return result.status === 0 && String(result.stdout).trim() !== ''
|
|
416
417
|
}
|
|
417
418
|
|
|
419
|
+
// Mirrors the desktop settings keys each voice pipeline cannot start without.
|
|
420
|
+
const VOICE_KEYS = Object.freeze({
|
|
421
|
+
dashscopeApiKey: 'DASHSCOPE_API_KEY',
|
|
422
|
+
deepseekApiKey: 'DEEPSEEK_API_KEY',
|
|
423
|
+
arkApiKey: 'ARK_API_KEY',
|
|
424
|
+
doubaoBigmodelApiKey: 'DOUBAO_BIGMODEL_API_KEY',
|
|
425
|
+
})
|
|
426
|
+
const CASCADED_LLM_KEYS = Object.freeze({qwen: 'dashscopeApiKey', deepseek: 'deepseekApiKey', ark: 'arkApiKey'})
|
|
427
|
+
|
|
428
|
+
function voiceRequirement(document) {
|
|
429
|
+
const pipeline = document?.pipelineMode === 'cascaded' ? 'cascaded' : 'integrated'
|
|
430
|
+
const llm = Object.hasOwn(CASCADED_LLM_KEYS, document?.cascadedLlmProvider) ? document.cascadedLlmProvider : 'deepseek'
|
|
431
|
+
return {pipeline, keys: pipeline === 'integrated' ? ['dashscopeApiKey'] : [CASCADED_LLM_KEYS[llm], 'doubaoBigmodelApiKey']}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const DASHSCOPE_COMPATIBLE_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
|
435
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/u
|
|
436
|
+
|
|
437
|
+
// Matches settings-store's validSecretEntry: anything else is dropped by the desktop and does not count as saved.
|
|
438
|
+
function savedSecret(document, key) {
|
|
439
|
+
const entry = document?.secrets?.[key]
|
|
440
|
+
return entry !== null && typeof entry === 'object' && ['safeStorage', 'none'].includes(entry.enc)
|
|
441
|
+
&& typeof entry.data === 'string' && entry.data !== '' && entry.data.length % 4 === 0 && BASE64.test(entry.data)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Saved keys win over the environment in the desktop, and stay encrypted, so only environment keys can be probed here.
|
|
445
|
+
async function inspectVoiceKeys(document, environment, {online, fetchImpl}) {
|
|
446
|
+
const {pipeline, keys} = voiceRequirement(document)
|
|
447
|
+
const sourceOf = (key, name) => savedSecret(document, key) ? 'settings' : environment[name]?.trim() ? 'environment' : null
|
|
448
|
+
const entries = []
|
|
449
|
+
for (const key of keys) {
|
|
450
|
+
let name = VOICE_KEYS[key]
|
|
451
|
+
let source = sourceOf(key, name)
|
|
452
|
+
// Integrated voice also accepts the generic model key while the model gateway stays on DashScope.
|
|
453
|
+
const savedBaseUrl = typeof document?.modelBaseUrl === 'string' ? document.modelBaseUrl.trim() : ''
|
|
454
|
+
const modelBaseUrl = savedBaseUrl || environment.MODEL_BASE_URL?.trim() || DASHSCOPE_COMPATIBLE_BASE_URL
|
|
455
|
+
if (source === null && key === 'dashscopeApiKey' && pipeline === 'integrated'
|
|
456
|
+
&& (modelBaseUrl === '' || modelBaseUrl === DASHSCOPE_COMPATIBLE_BASE_URL) && sourceOf('modelApiKey', 'MODEL_API_KEY') !== null) {
|
|
457
|
+
name = 'MODEL_API_KEY'
|
|
458
|
+
source = sourceOf('modelApiKey', name)
|
|
459
|
+
}
|
|
460
|
+
const probe = online && source === 'environment' ? (await probeApiKey(key, environment[name], {fetch: fetchImpl})).status : undefined
|
|
461
|
+
entries.push(Object.freeze({name, source, ...(probe === undefined ? {} : {probe})}))
|
|
462
|
+
}
|
|
463
|
+
return Object.freeze({pipeline, keys: Object.freeze(entries)})
|
|
464
|
+
}
|
|
465
|
+
|
|
418
466
|
export async function inspectDoctor({
|
|
419
467
|
platform = process.platform,
|
|
420
468
|
arch = process.arch,
|
|
421
469
|
home,
|
|
422
470
|
environment = process.env,
|
|
471
|
+
online = false,
|
|
472
|
+
fetchImpl = globalThis.fetch,
|
|
423
473
|
} = {}) {
|
|
424
474
|
let target
|
|
425
475
|
try {
|
|
@@ -432,8 +482,9 @@ export async function inspectDoctor({
|
|
|
432
482
|
const settings = desktopSettingsPath({platform, home, environment})
|
|
433
483
|
let secretKeys = []
|
|
434
484
|
let capabilitiesConfigPath
|
|
485
|
+
let document
|
|
435
486
|
try {
|
|
436
|
-
|
|
487
|
+
document = JSON.parse(await readFile(settings, 'utf8'))
|
|
437
488
|
// Match settings-store v4 migration/string validation, then backendLaunchSpec's nonempty saved-path override.
|
|
438
489
|
const acceptsV4Fields = document?.version === undefined || (typeof document.version === 'number' && document.version >= 4)
|
|
439
490
|
const candidate = document?.capabilitiesConfigPath
|
|
@@ -451,10 +502,11 @@ export async function inspectDoctor({
|
|
|
451
502
|
desktopReady: await cachedInstallation({root, executable, target, platform}) !== null,
|
|
452
503
|
settingsPresent: await access(settings).then(() => true, () => false),
|
|
453
504
|
configuredSecretKeys: Object.freeze(secretKeys),
|
|
505
|
+
voice: await inspectVoiceKeys(document, environment, {online, fetchImpl}),
|
|
454
506
|
codexPresent: findCodex(platform),
|
|
455
507
|
capabilities: inspectCapabilities({
|
|
456
508
|
environment: capabilitiesConfigPath === undefined ? environment : {
|
|
457
|
-
...environment,
|
|
509
|
+
...environment, CAPABILITIES_CONFIG: capabilitiesConfigPath,
|
|
458
510
|
},
|
|
459
511
|
...(home === undefined ? {} : {home}),
|
|
460
512
|
}),
|
package/src/target.mjs
CHANGED