fix(voice-input): explain model preparation download failures
This commit is contained in:
1 parent
da7ca4265a
commit
fcd89f691a
23 files changed
+349
-34
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- alert:
|
||||
- paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 503."
|
||||
- paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later.
|
||||
- text: "Download source: https://model-mirror.example"
|
||||
@@ -0,0 +1,61 @@
|
||||
/** The real local provider publishes actionable download failures through the speech Remote to bundle details. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser } from 'playwright'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage } from './support.ts'
|
||||
|
||||
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
|
||||
const expected = fileURLToPath(new URL('./expected/voice-download.expected.md', import.meta.url))
|
||||
|
||||
it('shows the failed asset, actual download source and recovery advice, then retries on request', async () => {
|
||||
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-voice-download-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
let status = 503
|
||||
const requests: string[] = []
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url!)
|
||||
response.writeHead(status); response.end()
|
||||
})
|
||||
onTestFinished(async () => {
|
||||
try { await resources.browser?.close() } finally {
|
||||
try { await resources.scaffold?.close() } finally {
|
||||
const closed = new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
|
||||
server.closeAllConnections(); await closed
|
||||
}
|
||||
}
|
||||
})
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening')
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Expected a loopback listener')
|
||||
const origin = `http://127.0.0.1:${address.port}`, overlay = join(root, 'voice.patch.yml')
|
||||
await writeFile(overlay, `- id: speech-to-text-sensevoice\n config:\n dataRoot: ${JSON.stringify(join(root, 'models'))}\n modelOrigin: ${origin}\n`)
|
||||
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: true }] }, extraOverlayPath: overlay })
|
||||
resources.scaffold = scaffold
|
||||
const browser = await chromium.launch()
|
||||
resources.browser = browser
|
||||
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click()
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
|
||||
const alert = page.getByRole('alert').filter({ hasText: 'Cannot download model.int8.onnx' })
|
||||
await alert.waitFor()
|
||||
expect(await alert.textContent()).toContain('HTTP 503')
|
||||
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]).toContain('/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')
|
||||
await compareOrRefreshGolden(expected,
|
||||
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(origin, 'https://model-mirror.example'), webSnapshotMode())
|
||||
status = 404
|
||||
await page.getByRole('button', { name: 'Retry preparation', exact: true }).click()
|
||||
await expect.poll(() => alert.textContent()).toContain('HTTP 404')
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
})
|
||||
@@ -27,6 +27,7 @@
|
||||
"tests/scaffold.ts",
|
||||
"tests/voice-input.e2e.ts",
|
||||
"tests/voice-setup.e2e.ts",
|
||||
"tests/voice-download.e2e.ts",
|
||||
"tests/prefix-proxy.ts",
|
||||
"tests/auto-review-fixture.ts",
|
||||
"tests/scaffold-generation.spec.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/voice-input.md
|
||||
voice-input.md: f1d3f36d067f756235e976fa6eafe70f94b19ccc
|
||||
voice-input.zh.md: a32b539fc1237bb70d68668de939084d5f634a82
|
||||
voice-input.md: 1c35bbe673a0814cf3fea0a5b9ca141ba075e8ab
|
||||
voice-input.zh.md: aa4d37a845a1694842ae6926985226c843b8160b
|
||||
@@ -20,6 +20,8 @@ The input facade captures a revision-bearing selection before recording. `InputA
|
||||
|
||||
The Host provider owns one preparation task across page and Session changes. The Client shares one `follow()` subscription across the composer, setup prompt and bundle details. Optional `SpeechSetupEstimate` metadata supplies provider-specific planning hints, separate from measured progress. `SpeechPreparationStepKind` identifies ordered resource operations; `SpeechPreparationStep` records each status and start time. The complete `SpeechPreparationState` retains these steps after cancellation or failure. The collapsed UI shows the current operation; expanding lists all steps, with byte progress or elapsed time only for the running step. Closing an observer never cancels preparation. Verified files survive retries and idle worker reclamation.
|
||||
|
||||
Failed preparation can include `SpeechDownloadFailure` with the asset, source origin, classified reason and optional diagnostic code or HTTP status. The Client localizes recovery advice; raw download causes remain on the Host.
|
||||
|
||||
The microphone occupies `conversation.input.activity`, between the model selector and Send. Clicking starts capture and expands the toolbar; Stop transcribes and inserts into the draft. The activity preserves the editor and submit action, owns local feedback, and releases expansion on unmount. Cancel, Escape or hiding the page discards capture. Waveform history displays measured microphone amplitude. Bundle details contain recognition preferences, preparation state and progress. Explicit enablement uses `plugins.bundle.activation` to guide users with missing models to setup; the list shows only the bundle description and switch.
|
||||
|
||||
## Design rationale
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、安装引导弹窗、Bundle 详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。
|
||||
|
||||
准备失败时可包含 `SpeechDownloadFailure`,提供文件、下载源、原因分类以及可选错误码或 HTTP 状态。Client 将恢复建议本地化;原始下载错误留在 Host。
|
||||
|
||||
麦克风占用模型选择器与发送按钮之间的 `conversation.input.activity`。点击开始录音并展开工具栏;停止后转写并插入草稿。活动栏保留编辑器与提交按钮,拥有局部反馈,并在卸载时释放展开状态。取消、Escape 或隐藏页面会丢弃录音。波形历史展示实测麦克风音量。Bundle 详情包含识别偏好、准备状态和进度。显式启用时通过 `plugins.bundle.activation` 引导缺少模型的用户前往安装;列表只显示 Bundle 描述和开关。
|
||||
|
||||
## 设计依据
|
||||
|
||||
@@ -29,6 +29,17 @@ function DownloadProgress({ state, t }: { state: SpeechPreparationState } & Prop
|
||||
? <progress className={css.progress} aria-label={t('downloadProgress')} value={state.completedBytes} max={state.totalBytes} /> : null
|
||||
}
|
||||
|
||||
function PreparationFailure({ state, t }: { state: Extract<SpeechPreparationState, { phase: 'failed' }> } & PropsLocale<typeof NS>) {
|
||||
const failure = state.download
|
||||
if (!failure) return <p className={css.preparationError} role="alert">{t('preparationFailed', { message: state.message })}</p>
|
||||
return <div className={css.downloadFailure} role="alert">
|
||||
<p className={css.preparationError}>{t(`download.${failure.reason}`, { resource: failure.resource, status: String(failure.status) })}</p>
|
||||
<p>{t(`downloadAdvice.${failure.reason}`)}</p>
|
||||
<small className={css.metric}>{t('downloadSource', { source: failure.source })}</small>
|
||||
{failure.code && <small className={css.metric}>{t('downloadCode', { code: failure.code })}</small>}
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Render a collapsed current-step summary or all Host-owned preparation steps. */
|
||||
export function PreparationCard({ provider, connected, prepare, cancelPreparation, t }: PreparationCardProps) {
|
||||
const state = provider.preparation
|
||||
@@ -88,7 +99,7 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio
|
||||
</ol>
|
||||
</DisclosureRow>
|
||||
{!expanded && <DownloadProgress state={state} t={t} />}
|
||||
{state.phase === 'failed' && <p className={css.preparationError} role="alert">{t('preparationFailed', { message: state.message })}</p>}
|
||||
{state.phase === 'failed' && <PreparationFailure state={state} t={t} />}
|
||||
<div className={css.preparationActions}>
|
||||
{['unprepared', 'cancelled', 'failed'].includes(state.phase) && <Button variant="outline" size="sm" disabled={!connected}
|
||||
onClick={() => { void run(() => prepare(provider.id)) }}>{t(state.phase === 'unprepared' ? 'prepare' : 'retryPrepare')}</Button>}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
.preparationActions { display: flex; gap: 8px; }
|
||||
.preparationActions:empty { display: none; }
|
||||
.preparationError { color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; font-size: 12px; }
|
||||
.downloadFailure { display: grid; gap: 6px; overflow-wrap: anywhere; }
|
||||
.summaryRow { min-height: 28px; height: auto; flex-wrap: wrap; gap: 4px; }
|
||||
.summaryTitle { flex: 1; min-width: 0; }
|
||||
.metric { font-size: 12px; color: var(--dsw-alias-label-tertiary); }
|
||||
|
||||
@@ -40,6 +40,24 @@ export const zh = {
|
||||
'downloadUnknown': '已下载 {completed} MB',
|
||||
'elapsed': '已等待 {seconds} 秒',
|
||||
'preparationFailed': '准备失败:{message}',
|
||||
'download.network': '无法下载 {resource}:连接下载服务失败。',
|
||||
'download.dns': '无法下载 {resource}:无法解析下载地址。',
|
||||
'download.timeout': '下载 {resource} 超时。',
|
||||
'download.certificate': '无法下载 {resource}:安全连接的证书校验失败。',
|
||||
'download.http': '无法下载 {resource}:下载服务返回 HTTP {status}。',
|
||||
'download.integrity': '{resource} 下载不完整或文件校验失败。',
|
||||
'download.storage': '无法保存 {resource}:磁盘空间不足或没有写入权限。',
|
||||
'download.unknown': '准备 {resource} 失败。',
|
||||
'downloadAdvice.network': '请检查运行 DSH 的机器能否访问下载来源及其模型下载服务;如需代理,请在该机器上配置后重试。',
|
||||
'downloadAdvice.dns': '请检查运行 DSH 的机器的 DNS 和代理设置,确认可以解析下载来源的域名后重试。',
|
||||
'downloadAdvice.timeout': '请检查运行 DSH 的机器的网络或代理连接,稍后重试。已下载并通过校验的文件会保留。',
|
||||
'downloadAdvice.certificate': '请检查运行 DSH 的机器的系统时间、受信任证书和代理设置后重试。',
|
||||
'downloadAdvice.http': '请确认下载地址可用、代理可正常连接下载服务,或稍后重试。',
|
||||
'downloadAdvice.integrity': '请重试下载。已完成并通过校验的其他文件会保留。',
|
||||
'downloadAdvice.storage': '请检查运行 DSH 的机器的剩余磁盘空间,以及模型目录的写入权限后重试。',
|
||||
'downloadAdvice.unknown': '请检查运行 DSH 的机器的网络、磁盘空间和模型目录权限后重试。',
|
||||
downloadSource: '下载来源:{source}',
|
||||
downloadCode: '错误码:{code}',
|
||||
'reconnecting': '正在连接语音服务…',
|
||||
'preparation.unprepared': '首次使用需要下载本地识别模型。',
|
||||
'preparation.checking': '正在检查本地资源…',
|
||||
@@ -109,6 +127,24 @@ export const en: Record<VoiceKey, string> = {
|
||||
'downloadUnknown': 'Downloaded {completed} MB',
|
||||
'elapsed': 'Waiting {seconds} seconds',
|
||||
'preparationFailed': 'Preparation failed: {message}',
|
||||
'download.network': 'Cannot download {resource}: connection to the download service failed.',
|
||||
'download.dns': 'Cannot download {resource}: the download address could not be resolved.',
|
||||
'download.timeout': 'The download of {resource} timed out.',
|
||||
'download.certificate': 'Cannot download {resource}: certificate verification failed.',
|
||||
'download.http': 'Cannot download {resource}: the download service returned HTTP {status}.',
|
||||
'download.integrity': 'The download of {resource} is incomplete or failed verification.',
|
||||
'download.storage': 'Cannot save {resource}: insufficient disk space or write permission.',
|
||||
'download.unknown': 'Could not prepare {resource}.',
|
||||
'downloadAdvice.network': 'Check that the machine running DSH can reach the download source and its model download services. Configure a proxy on that machine if needed, then retry.',
|
||||
'downloadAdvice.dns': 'Check DNS and proxy settings on the machine running DSH. Confirm that it can resolve the download source domain, then retry.',
|
||||
'downloadAdvice.timeout': 'Check the network or proxy connection on the machine running DSH, then retry later. Verified downloaded files are retained.',
|
||||
'downloadAdvice.certificate': 'Check the system clock, trusted certificates and proxy settings on the machine running DSH, then retry.',
|
||||
'downloadAdvice.http': 'Confirm that the download address is available and the proxy can reach the download service, or retry later.',
|
||||
'downloadAdvice.integrity': 'Retry the download. Other completed and verified files are retained.',
|
||||
'downloadAdvice.storage': 'Check available disk space and write permission for the model directory on the machine running DSH, then retry.',
|
||||
'downloadAdvice.unknown': 'Check the network, disk space and model directory permissions on the machine running DSH, then retry.',
|
||||
downloadSource: 'Download source: {source}',
|
||||
downloadCode: 'Error code: {code}',
|
||||
'reconnecting': 'Connecting to speech recognition…',
|
||||
'preparation.unprepared': 'First use downloads the local recognition models.',
|
||||
'preparation.checking': 'Checking local resources…',
|
||||
|
||||
@@ -84,6 +84,30 @@ it('shows waking without an explicit preparation cancellation control', () => {
|
||||
expect(screen.getByText('已等待 5 秒')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: zh.cancelPrepare })).toBeNull()
|
||||
})
|
||||
it('explains download failures on the Host machine and retains an actionable retry', () => {
|
||||
const b = fixture({ phase: 'failed', message: 'fetch failed', download: {
|
||||
resource: 'model.int8.onnx', source: 'https://mirror.example', reason: 'dns', code: 'ENOTFOUND',
|
||||
} })
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toContain('无法下载 model.int8.onnx:无法解析下载地址。')
|
||||
expect(alert.textContent).toContain('运行 DSH 的机器的 DNS 和代理设置')
|
||||
expect(alert.textContent).toContain('下载来源:https://mirror.example')
|
||||
expect(alert.textContent).toContain('错误码:ENOTFOUND')
|
||||
expect(alert.textContent).not.toContain('fetch failed')
|
||||
expect(alert.textContent).not.toContain('Hugging Face')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh.retryPrepare }))
|
||||
expect(b.prepare).toHaveBeenCalledWith(id)
|
||||
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider, preparation: {
|
||||
phase: 'failed', message: 'download unavailable', download: {
|
||||
resource: 'tokens.txt', source: 'https://huggingface.co', reason: 'http', status: 503,
|
||||
},
|
||||
} }} />)
|
||||
expect(screen.getByRole('alert').textContent).toContain('无法下载 tokens.txt:下载服务返回 HTTP 503。')
|
||||
expect(screen.getByRole('alert').textContent).not.toContain('错误码')
|
||||
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider,
|
||||
preparation: { phase: 'failed', message: 'runtime missing' } }} />)
|
||||
expect(screen.getByRole('alert').textContent).toBe('准备失败:runtime missing')
|
||||
})
|
||||
it('persists settings and reports disconnection in the detail card', async () => {
|
||||
const store = createSnapshotStore<SpeechReadiness>({ connected: true, error: null, catalog: {
|
||||
selection: { providerId: id, language: 'auto' }, maxAudioBytes: 100, maxDurationSeconds: 120,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/experimental/speech-to-text-sensevoice/README.md
|
||||
README.md: ea751ca1e080ac4805a4cdff58e47dfb9b1dc75d
|
||||
README.zh.md: fc142d1f8bd0f46d93904a29915d985ed9eb2ba0
|
||||
README.md: 412b179d03e8b6a9eff143a5884bfb393996249b
|
||||
README.zh.md: b9a00d7ba9cb454fab9b1153bb1855107b08a34a
|
||||
@@ -27,6 +27,8 @@ This provider recognizes speech with SenseVoiceSmall ONNX and Silero VAD on the
|
||||
|
||||
The [bundle](../voice-input-bundle/README.md) supplies an absolute `dataRoot` under the DSH home. Preparation downloads revision-pinned model files, verifies their sizes and SHA-256 hashes, then loads them. `precision` defaults to `int8`; `fp32` selects the larger reference weights. `modelDirectory` supplies an existing absolute directory containing the selected ONNX file and `tokens.txt`; `vadModelPath` selects an existing Silero ONNX file. `modelOrigin` selects a Hugging Face-compatible download origin while preserving pinned paths and checksums. Verified completed files remain reusable after cancellation or failure.
|
||||
|
||||
Download failures identify the asset and source origin, with a classified cause and HTTP status or diagnostic code when available. The public state omits URL credentials, query strings and raw cause messages. Retry reuses verified files; partial downloads restart.
|
||||
|
||||
Every activation checks the selected model, tokens and VAD on disk. Complete verified caches restore readiness immediately after inspection; the first recording wakes the worker. Missing files or mismatched managed checksums require explicit preparation, while unreadable paths report an error. Explicit deployment paths are checked for accessibility; their contents remain the deployer's responsibility. Disabling the plugin or restarting the Host does not require downloading intact caches again.
|
||||
|
||||
-----
|
||||
|
||||
@@ -27,6 +27,8 @@ kind: "package-reference"
|
||||
|
||||
[Bundle](../voice-input-bundle/README.zh.md) 在 DSH 主目录下提供绝对 `dataRoot`。准备会下载固定修订的模型文件,校验大小与 SHA-256,再加载模型。`precision` 默认为 `int8`,`fp32` 选择较大的参考权重。`modelDirectory` 提供包含所选 ONNX 文件和 `tokens.txt` 的已有绝对目录,`vadModelPath` 选择已有 Silero ONNX 文件。`modelOrigin` 选择兼容 Hugging Face 的下载源,固定路径和校验值保持不变。取消或失败后复用已完成并通过校验的文件。
|
||||
|
||||
下载失败会标明文件与下载源,并提供原因分类,以及可用的 HTTP 状态或错误码。公开状态不包含 URL 凭据、查询参数和原始底层错误消息。重试复用已校验文件,未完成的文件重新下载。
|
||||
|
||||
每次启用都会检查磁盘上的所选模型、词表与 VAD。缓存完整且校验通过后立即恢复就绪,首次录音再唤醒工作进程。文件缺失或托管文件校验不符时需要显式准备;路径不可读时报告错误。显式部署路径只检查可访问性,文件内容由部署者负责。禁用插件或重启 Host 后,无需重新下载完好的缓存。
|
||||
|
||||
-----
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Download failures retain their cause locally and expose only safe, structured diagnostics to clients. */
|
||||
import type { SpeechDownloadFailure } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
|
||||
import { TimeoutReason } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
type FailureKind = Pick<SpeechDownloadFailure, 'reason' | 'code'>
|
||||
|
||||
const codes: readonly [SpeechDownloadFailure['reason'], RegExp][] = [
|
||||
['dns', /^(ENOTFOUND|EAI_AGAIN)$/],
|
||||
['timeout', /^(ETIMEDOUT|ERR_SOCKET_CONNECTION_TIMEOUT|UND_ERR_(CONNECT|HEADERS|BODY)_TIMEOUT)$/],
|
||||
['certificate', /^(CERT_[A-Z_]+|ERR_TLS_CERT_ALTNAME_INVALID|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|UNABLE_TO_GET_ISSUER_CERT_LOCALLY)$/],
|
||||
['storage', /^(ENOSPC|EDQUOT|EACCES|EPERM|EROFS)$/],
|
||||
['network', /^(ECONNREFUSED|ECONNRESET|ENETUNREACH|EHOSTUNREACH|EPIPE|UND_ERR_SOCKET)$/],
|
||||
]
|
||||
|
||||
/**
|
||||
* Inspect native fetch causes, including aggregate connection attempts, without publishing their messages.
|
||||
* @param failure - error received from the network or filesystem.
|
||||
* @returns an actionable category and recognized diagnostic code, or a generic failure.
|
||||
*/
|
||||
export function classifyDownloadFailure(failure: unknown): FailureKind {
|
||||
const pending: unknown[] = [failure], visited = new Set<Error>()
|
||||
let reason: SpeechDownloadFailure['reason'] = 'unknown'
|
||||
while (pending.length > 0) {
|
||||
const error = pending.shift()
|
||||
if (!(error instanceof Error) || visited.has(error)) continue
|
||||
visited.add(error)
|
||||
if (error instanceof TimeoutReason || error.name === 'TimeoutError') return { reason: 'timeout' }
|
||||
if ('code' in error && typeof error.code === 'string') {
|
||||
for (const [kind, pattern] of codes) if (pattern.test(error.code)) return { reason: kind, code: error.code }
|
||||
}
|
||||
if (error instanceof TypeError && error.message === 'fetch failed') reason = 'network'
|
||||
pending.push(error.cause)
|
||||
if (error instanceof AggregateError) {
|
||||
const causes: readonly unknown[] = error.errors
|
||||
pending.push(...causes)
|
||||
}
|
||||
}
|
||||
return { reason }
|
||||
}
|
||||
|
||||
/** A preparation error whose public details exclude raw causes, credentials, signed URLs and local paths. */
|
||||
export class SpeechDownloadError extends Error {
|
||||
constructor(readonly download: SpeechDownloadFailure, options?: ErrorOptions) {
|
||||
super(`Unable to prepare ${download.resource} from ${download.source}: ${download.reason}${download.code ? ` (${download.code})` : ''}${download.status === undefined ? '' : ` (HTTP ${download.status})`}`, options)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { z } from 'zod'
|
||||
import type { Config } from './config.ts'
|
||||
import { SpeechInputError } from './input.ts'
|
||||
import { SpeechDownloadError } from './download-error.ts'
|
||||
import { inspectRuntime, prepareRuntime, type RuntimePaths } from './runtime.ts'
|
||||
|
||||
const transcriptSchema = z.object({
|
||||
@@ -186,7 +187,8 @@ export class SenseVoiceWorker {
|
||||
.catch((error: unknown) => {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
this.publish(abort.signal.aborted ? { phase: 'cancelled' }
|
||||
: { phase: 'failed', message: error instanceof Error ? error.message : String(error) })
|
||||
: { phase: 'failed', message: error instanceof Error ? error.message : String(error),
|
||||
...error instanceof SpeechDownloadError ? { download: error.download } : {} })
|
||||
}).finally(() => { this.preparing = undefined })
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-subprocess'
|
||||
import type { Config } from './config.ts'
|
||||
import type { SpeechPreparationState } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
|
||||
import { timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { classifyDownloadFailure, SpeechDownloadError } from './download-error.ts'
|
||||
|
||||
/** Release-pinned downloadable file. */
|
||||
export interface Asset {
|
||||
@@ -96,36 +98,50 @@ export async function inspectRuntime(config: Config, signal: AbortSignal): Promi
|
||||
* @param root - provider-owned cache directory.
|
||||
* @param signal - preparation cancellation.
|
||||
* @param report - Host-owned progress publisher.
|
||||
* @returns verified local file path.
|
||||
* @returns verified local file path; failures carry localized-UI diagnostics through SpeechDownloadError.
|
||||
*/
|
||||
export async function downloadAsset(asset: Asset, root: string, signal: AbortSignal,
|
||||
report: (state: SpeechPreparationState) => void = () => {}): Promise<string> {
|
||||
signal.throwIfAborted()
|
||||
await mkdir(root, { recursive: true })
|
||||
const destination = join(root, asset.name)
|
||||
if (await matchesAsset(destination, asset, signal)) return destination
|
||||
const partial = `${destination}.${randomUUID()}.part`
|
||||
let source = new URL(asset.url).origin
|
||||
try {
|
||||
const response = await fetch(asset.url, { signal })
|
||||
if (!response.ok || !response.body) throw new Error(`Speech asset download failed: HTTP ${response.status}`)
|
||||
const digest = createHash('sha256')
|
||||
let completedBytes = 0
|
||||
const publish = (): void => { report({ phase: 'downloading', resource: asset.name, completedBytes, totalBytes: asset.bytes }) }
|
||||
publish()
|
||||
const hashing = new Transform({ transform(chunk: Buffer, _encoding, callback) {
|
||||
completedBytes += chunk.length
|
||||
if (completedBytes > asset.bytes) { callback(new Error(`Speech asset exceeds its pinned size: ${asset.name}`)); return }
|
||||
digest.update(chunk); publish(); callback(null, chunk)
|
||||
} })
|
||||
await pipeline(response.body, hashing, createWriteStream(partial, { flags: 'wx', mode: 0o600 }), { signal })
|
||||
if (completedBytes !== asset.bytes || digest.digest('hex') !== asset.sha256) {
|
||||
throw new Error(`Speech asset checksum or size mismatch: ${asset.name}`)
|
||||
await mkdir(root, { recursive: true })
|
||||
if (await matchesAsset(destination, asset, signal)) return destination
|
||||
try {
|
||||
const response = await fetch(asset.url, { signal })
|
||||
source = new URL(response.url || asset.url).origin
|
||||
if (!response.ok || !response.body) {
|
||||
throw new SpeechDownloadError({ resource: asset.name, source, reason: 'http', status: response.status })
|
||||
}
|
||||
const digest = createHash('sha256')
|
||||
let completedBytes = 0
|
||||
const publish = (): void => { report({ phase: 'downloading', resource: asset.name, completedBytes, totalBytes: asset.bytes }) }
|
||||
publish()
|
||||
const hashing = new Transform({ transform(chunk: Buffer, _encoding, callback) {
|
||||
completedBytes += chunk.length
|
||||
if (completedBytes > asset.bytes) {
|
||||
callback(new SpeechDownloadError({ resource: asset.name, source, reason: 'integrity' })); return
|
||||
}
|
||||
digest.update(chunk); publish(); callback(null, chunk)
|
||||
} })
|
||||
await pipeline(response.body, hashing, createWriteStream(partial, { flags: 'wx', mode: 0o600 }), { signal })
|
||||
if (completedBytes !== asset.bytes || digest.digest('hex') !== asset.sha256) {
|
||||
throw new SpeechDownloadError({ resource: asset.name, source, reason: 'integrity' })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await rename(partial, destination)
|
||||
return destination
|
||||
} finally {
|
||||
await rm(partial, { force: true })
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
await rename(partial, destination)
|
||||
return destination
|
||||
} finally {
|
||||
await rm(partial, { force: true })
|
||||
} catch (error) {
|
||||
const timedOut = timeoutOf(signal)
|
||||
if (signal.aborted && !timedOut || error instanceof SpeechDownloadError) throw error
|
||||
throw new SpeechDownloadError({ resource: asset.name, source,
|
||||
...timedOut ? { reason: 'timeout' } : classifyDownloadFailure(error),
|
||||
}, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Download diagnostics distinguish actionable causes without publishing raw network errors. */
|
||||
import { TimeoutReason } from '@deepseek-ai/dsh-timeout'
|
||||
import { expect, it } from 'vitest'
|
||||
import { classifyDownloadFailure } from '../src/download-error.ts'
|
||||
|
||||
it.each([
|
||||
['ENOTFOUND', 'dns'], ['EAI_AGAIN', 'dns'], ['ETIMEDOUT', 'timeout'], ['UND_ERR_CONNECT_TIMEOUT', 'timeout'],
|
||||
['CERT_HAS_EXPIRED', 'certificate'], ['SELF_SIGNED_CERT_IN_CHAIN', 'certificate'],
|
||||
['ENOSPC', 'storage'], ['EACCES', 'storage'], ['ECONNREFUSED', 'network'], ['UND_ERR_SOCKET', 'network'],
|
||||
])('classifies nested %s as %s', (code, reason) => {
|
||||
const cause = Object.assign(new Error('private details'), { code })
|
||||
expect(classifyDownloadFailure(new TypeError('fetch failed', { cause: new AggregateError([new Error('unknown'), cause]) })))
|
||||
.toEqual({ reason, code })
|
||||
})
|
||||
|
||||
it('recognizes deadlines and preserves generic classifications when a cause is unavailable', () => {
|
||||
expect(classifyDownloadFailure(new TimeoutReason('SPEECH_PREPARE_TIMEOUT', 1000))).toEqual({ reason: 'timeout' })
|
||||
expect(classifyDownloadFailure(new DOMException('expired', 'TimeoutError'))).toEqual({ reason: 'timeout' })
|
||||
expect(classifyDownloadFailure(new TypeError('fetch failed'))).toEqual({ reason: 'network' })
|
||||
expect(classifyDownloadFailure(Object.assign(new Error('unknown'), { code: 'PRIVATE_DATA' }))).toEqual({ reason: 'unknown' })
|
||||
expect(classifyDownloadFailure('unknown rejection')).toEqual({ reason: 'unknown' })
|
||||
const cyclic = new Error('unknown')
|
||||
cyclic.cause = cyclic
|
||||
expect(classifyDownloadFailure(cyclic)).toEqual({ reason: 'unknown' })
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TimeoutReason } from '@deepseek-ai/dsh-timeout'
|
||||
import { downloadAsset } from '../src/runtime.ts'
|
||||
|
||||
const cleanup: Array<() => Promise<void>> = []
|
||||
@@ -33,17 +34,68 @@ it('publishes verified files, reuses them offline, and replaces corrupted cached
|
||||
it('rejects altered downloads and HTTP failures without leaving partial files', async () => {
|
||||
const root = await fixture()
|
||||
vi.stubGlobal('fetch', async () => new Response('corrupt'))
|
||||
await expect(downloadAsset(asset, root, signal())).rejects.toThrow('checksum')
|
||||
await expect(downloadAsset(asset, root, signal())).rejects.toMatchObject({ download: { reason: 'integrity', resource: asset.name } })
|
||||
vi.stubGlobal('fetch', async () => new Response(bytes))
|
||||
await expect(downloadAsset({ ...asset, sha256: 'bad-digest' }, root, signal())).rejects.toThrow('checksum')
|
||||
await expect(downloadAsset({ ...asset, bytes: 1 }, root, signal())).rejects.toThrow('pinned size')
|
||||
await expect(downloadAsset({ ...asset, sha256: 'bad-digest' }, root, signal())).rejects.toMatchObject({ download: { reason: 'integrity' } })
|
||||
await expect(downloadAsset({ ...asset, bytes: 1 }, root, signal())).rejects.toMatchObject({ download: { reason: 'integrity' } })
|
||||
vi.stubGlobal('fetch', async () => new Response(null, { status: 503 }))
|
||||
await expect(downloadAsset(asset, root, signal())).rejects.toThrow('503')
|
||||
await expect(downloadAsset(asset, root, signal())).rejects.toMatchObject({ download: {
|
||||
reason: 'http', resource: asset.name, source: 'https://example.invalid', status: 503,
|
||||
} })
|
||||
vi.stubGlobal('fetch', async () => new Response(null, { status: 200 }))
|
||||
await expect(downloadAsset(asset, root, signal())).rejects.toThrow('200')
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports fetch causes without exposing credentials, query strings or raw cause messages', async () => {
|
||||
const root = await fixture()
|
||||
const cause = Object.assign(new Error('private proxy details'), { code: 'ENOTFOUND' })
|
||||
vi.stubGlobal('fetch', async () => { throw new TypeError('fetch failed', { cause }) })
|
||||
await expect(downloadAsset({ ...asset, url: 'https://user:[email protected]/model?token=private' }, root, signal()))
|
||||
.rejects.toMatchObject({
|
||||
message: 'Unable to prepare model.bin from https://example.invalid: dns (ENOTFOUND)',
|
||||
download: { reason: 'dns', resource: 'model.bin', source: 'https://example.invalid', code: 'ENOTFOUND' },
|
||||
})
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports the redirected source and cleans interrupted downloads while preserving verified files for retry', async () => {
|
||||
const root = await fixture()
|
||||
vi.stubGlobal('fetch', async () => new Response(bytes))
|
||||
await downloadAsset(asset, root, signal())
|
||||
const other = { ...asset, name: 'tokens.txt' }
|
||||
const response = new Response(new ReadableStream({ start(controller) {
|
||||
controller.enqueue(bytes.subarray(0, 2))
|
||||
controller.error(Object.assign(new Error('connection closed'), { code: 'ECONNRESET' }))
|
||||
} }))
|
||||
Object.defineProperty(response, 'url', { value: 'https://cdn.example.invalid/model?signature=private' })
|
||||
vi.stubGlobal('fetch', async () => response)
|
||||
await expect(downloadAsset(other, root, signal())).rejects.toMatchObject({ download: {
|
||||
reason: 'network', source: 'https://cdn.example.invalid', code: 'ECONNRESET', resource: 'tokens.txt',
|
||||
} })
|
||||
expect(await readdir(root)).toEqual(['model.bin'])
|
||||
const retry = vi.fn(async () => new Response(bytes))
|
||||
vi.stubGlobal('fetch', retry)
|
||||
await downloadAsset(asset, root, signal()); await downloadAsset(other, root, signal())
|
||||
expect(retry).toHaveBeenCalledOnce()
|
||||
expect(await readFile(join(root, 'model.bin'))).toEqual(bytes)
|
||||
})
|
||||
|
||||
it.each([false, true])('distinguishes explicit cancellation from a preparation deadline (timeout: %s)', async (timedOut) => {
|
||||
const root = await fixture(), abort = new AbortController(), started = Promise.withResolvers<undefined>()
|
||||
const reason = timedOut ? new TimeoutReason('SPEECH_PREPARE_TIMEOUT', 1000) : new Error('user cancelled')
|
||||
vi.stubGlobal('fetch', async () => await new Promise<Response>((_resolve, reject) => {
|
||||
abort.signal.addEventListener('abort', () => { reject(reason) }, { once: true })
|
||||
started.resolve(undefined)
|
||||
}))
|
||||
const pending = downloadAsset(asset, root, abort.signal)
|
||||
const rejected = timedOut ? expect(pending).rejects.toMatchObject({ download: { reason: 'timeout', resource: asset.name } })
|
||||
: expect(pending).rejects.toBe(reason)
|
||||
cleanup.push(async () => { abort.abort(); await pending.catch(() => {}) })
|
||||
await started.promise; abort.abort(reason); await rejected
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels streaming downloads and refuses invalid cache locations', async () => {
|
||||
const root = await fixture(), cancel = new AbortController(), reading = Promise.withResolvers<undefined>()
|
||||
vi.stubGlobal('fetch', async () => new Response(new ReadableStream({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { Config } from '../src/config.ts'
|
||||
import { SenseVoiceWorker } from '../src/recognizer.ts'
|
||||
import { inspectRuntime, prepareRuntime } from '../src/runtime.ts'
|
||||
import { SpeechDownloadError } from '../src/download-error.ts'
|
||||
|
||||
vi.mock('../src/runtime.ts', () => ({ inspectRuntime: vi.fn(), prepareRuntime: vi.fn() }))
|
||||
const cleanup: Array<() => Promise<void>> = []
|
||||
@@ -217,6 +218,18 @@ it('recovers after a provider error or unexpected worker exit', async () => {
|
||||
expect((await worker.transcribe({ audio, language: 'zh' }, signal())).text).toBe('zh')
|
||||
})
|
||||
|
||||
it('publishes safe download diagnostics and clears them after a successful retry', async () => {
|
||||
const { worker } = await fixture()
|
||||
const download = { resource: 'model.int8.onnx', source: 'https://mirror.example', reason: 'dns' as const, code: 'ENOTFOUND' }
|
||||
vi.mocked(prepareRuntime).mockRejectedValueOnce(new SpeechDownloadError(download, { cause: new Error('private details') }))
|
||||
worker.prepare()
|
||||
await vi.waitFor(() => { expect(worker.snapshot()).toMatchObject({ phase: 'failed', download }) })
|
||||
expect(JSON.stringify(worker.snapshot())).not.toContain('private details')
|
||||
await prepare(worker)
|
||||
expect(worker.snapshot()).toMatchObject({ phase: 'ready' })
|
||||
expect(worker.snapshot()).not.toHaveProperty('download')
|
||||
})
|
||||
|
||||
it('applies inference deadlines and reports runtime preparation failure', async () => {
|
||||
const { worker } = await fixture({ inferenceTimeoutMs: 100 })
|
||||
await prepare(worker)
|
||||
|
||||
@@ -32,12 +32,21 @@ export interface SpeechPreparationStep {
|
||||
readonly startedAt?: number
|
||||
}
|
||||
|
||||
/** Safe download diagnostics for localized preparation guidance; URLs omit credentials and query strings. */
|
||||
export interface SpeechDownloadFailure {
|
||||
readonly resource: string
|
||||
readonly source: string
|
||||
readonly reason: 'network' | 'dns' | 'timeout' | 'certificate' | 'http' | 'integrity' | 'storage' | 'unknown'
|
||||
readonly code?: string
|
||||
readonly status?: number
|
||||
}
|
||||
|
||||
/** Host-owned preparation state; byte totals describe downloads, never estimated installation percentages. */
|
||||
export type SpeechPreparationState = (
|
||||
| { readonly phase: 'unprepared' | 'ready' | 'standby' | 'cancelled' }
|
||||
| { readonly phase: 'downloading'; readonly resource: string; readonly completedBytes: number; readonly totalBytes?: number }
|
||||
| { readonly phase: 'checking' | 'loading' | 'waking' | 'cancelling'; readonly startedAt: number }
|
||||
| { readonly phase: 'failed'; readonly message: string }
|
||||
| { readonly phase: 'failed'; readonly message: string; readonly download?: SpeechDownloadFailure }
|
||||
) & {
|
||||
readonly step?: SpeechPreparationStepKind
|
||||
readonly steps?: readonly SpeechPreparationStep[]
|
||||
|
||||
@@ -6562,6 +6562,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SpeechCatalog',
|
||||
declaration: 'export interface SpeechCatalog extends SpeechSnapshot {\n readonly maxAudioBytes: number;\n readonly maxDurationSeconds: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpeechDownloadFailure',
|
||||
declaration: 'export interface SpeechDownloadFailure {\n readonly resource: string;\n readonly source: string;\n readonly reason: \'network\' | \'dns\' | \'timeout\' | \'certificate\' | \'http\' | \'integrity\' | \'storage\' | \'unknown\';\n readonly code?: string;\n readonly status?: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SpeechInput',
|
||||
declaration: 'export interface SpeechInput {\n readonly audio: Uint8Array;\n readonly language: string;\n}',
|
||||
@@ -6572,7 +6576,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SpeechPreparationState',
|
||||
declaration: 'export type SpeechPreparationState = ({\n readonly phase: \'unprepared\' | \'ready\' | \'standby\' | \'cancelled\';\n} | {\n readonly phase: \'downloading\';\n readonly resource: string;\n readonly completedBytes: number;\n readonly totalBytes?: number;\n} | {\n readonly phase: \'checking\' | \'loading\' | \'waking\' | \'cancelling\';\n readonly startedAt: number;\n} | {\n readonly phase: \'failed\';\n readonly message: string;\n}) & {\n readonly step?: SpeechPreparationStepKind;\n readonly steps?: readonly SpeechPreparationStep[];\n};',
|
||||
declaration: 'export type SpeechPreparationState = ({\n readonly phase: \'unprepared\' | \'ready\' | \'standby\' | \'cancelled\';\n} | {\n readonly phase: \'downloading\';\n readonly resource: string;\n readonly completedBytes: number;\n readonly totalBytes?: number;\n} | {\n readonly phase: \'checking\' | \'loading\' | \'waking\' | \'cancelling\';\n readonly startedAt: number;\n} | {\n readonly phase: \'failed\';\n readonly message: string;\n readonly download?: SpeechDownloadFailure;\n}) & {\n readonly step?: SpeechPreparationStepKind;\n readonly steps?: readonly SpeechPreparationStep[];\n};',
|
||||
},
|
||||
{
|
||||
name: 'SpeechPreparationStep',
|
||||
|
||||
@@ -613,6 +613,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
JobEventListener: 'jobs.md',
|
||||
JobEvents: 'jobs.md',
|
||||
JobSettleCause: 'jobs.md',
|
||||
SpeechDownloadFailure: 'voice-input.md',
|
||||
SpeechPreparationState: 'voice-input.md',
|
||||
SpeechPreparationStep: 'voice-input.md',
|
||||
SpeechPreparationStepKind: 'voice-input.md',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"apps/web/tests/scaffold.ts",
|
||||
"apps/web/tests/voice-input.e2e.ts",
|
||||
"apps/web/tests/voice-setup.e2e.ts",
|
||||
"apps/web/tests/voice-download.e2e.ts",
|
||||
"apps/web/tests/prefix-proxy.ts",
|
||||
"apps/web/tests/developer-tools-settings.e2e.ts",
|
||||
"apps/web/tests/auto-review-fixture.ts",
|
||||
|
||||
Reference in new issue
Block a user