Merge remote-tracking branch 'origin/master' into fix/preset-ui-20260921

This commit is contained in:
turtle1999 committed 2026-09-23 14:45:13 +08:00
commit 02383832df
78 files changed
+1169 -138

No files matched your search

@@ -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 .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md
2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be
2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe
2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343
2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c
@@ -16,6 +16,8 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
@@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
## Consequences
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card.
## Testing
`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.
`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address.
@@ -16,6 +16,8 @@ Status: implemented
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
@@ -36,8 +38,10 @@ Status: implemented
## 后果
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
## 测试
`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。
`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。
@@ -0,0 +1,6 @@
- dialog "连接 GitHub 超时":
- heading "连接 GitHub 超时" [level=2]
- button "关闭"
- paragraph: 请尝试其他安装来源。
- button "取消"
- button "改用国内镜像"
@@ -0,0 +1,4 @@
- alert:
- paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 502."
- paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later.
- text: "Download source: https://fallback-model-source.example"
@@ -0,0 +1,19 @@
- strong: SenseVoiceSmall (INT8)
- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required.
- term: Disk space
- definition: Allow about 1 GB for models, runtime, and download caches
- term: Memory
- definition: About 1 GB with the model loaded; recognition may use more
- term: First setup
- definition: Allow roughly 1–10 minutes, depending on the network and machine
- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default.
- button "First use downloads the local recognition models.":
- text: First use downloads the local recognition models.
- status
- text: Model download source
- combobox "Model download source":
- option "Automatic (recommended)" [selected]
- option "Hugging Face"
- option "HF-Mirror (China mirror)"
- text: Prefer the first responding source and try another if a download fails.
- button "Download and prepare"
@@ -0,0 +1,19 @@
- strong: SenseVoiceSmall (INT8)
- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required.
- term: Disk space
- definition: Allow about 1 GB for models, runtime, and download caches
- term: Memory
- definition: About 1 GB with the model loaded; recognition may use more
- term: First setup
- definition: Allow roughly 1–10 minutes, depending on the network and machine
- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default.
- button "First use downloads the local recognition models.":
- text: First use downloads the local recognition models.
- status
- text: Model download source
- combobox "Model download source":
- option "Automatic (recommended)"
- option "Hugging Face"
- option "HF-Mirror (China mirror)" [selected]
- text: Download only from the selected source. Verified downloaded files are reused.
- button "Download and prepare"
+1 -1
View File
@@ -397,7 +397,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
const picker = page.getByRole('dialog', { name: '选择要添加的模型' })
await picker.getByRole('button', { name: '取消全选' }).click()
await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra')
await picker.getByRole('checkbox', { name: 'GPT-6 Astra', exact: true }).check()
await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check()
await picker.getByRole('button', { name: '添加所选' }).click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
expect(await image.isChecked()).toBe(true)
+41 -11
View File
@@ -1,6 +1,8 @@
// The real installer classifies a controlled GitHub network failure; the browser
// Real Git reaches a controlled proxy failure or stall; the browser
// offers a mirror for a replacement spec without retrying the failed address.
import { once } from 'node:events'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { createServer, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,12 +11,33 @@ import { expect, it, onTestFinished } from 'vitest'
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
it('offers a mirror after GitHub fails and waits for replacement input', async () => {
it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and waits for replacement input', async (failure) => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-github-'))
onTestFinished(() => rm(scratch, { recursive: true, force: true }))
const sockets = new Set<Socket>()
let connections = 0
const proxy = createServer((socket) => {
sockets.add(socket)
socket.on('error', () => {})
socket.once('close', () => { sockets.delete(socket) })
socket.once('data', () => { connections++; if (failure === 'network') socket.destroy() })
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) })
})
proxy.listen(0, '127.0.0.1')
await once(proxy, 'listening')
const address = proxy.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
const gitConfig = join(scratch, 'git.config')
await writeFile(gitConfig, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`)
const overlay = join(scratch, 'cordis.patch.yml')
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
const scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
await writeFile(overlay, '- id: ui-plugin-manager\n config: { registryProbeEnabled: false }\n')
const scaffold = await launchWebScaffold({
profile: { packages: [], packageManager: { command: process.execPath, args: [], env: { GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' } } },
extraOverlayPath: overlay,
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
@@ -22,10 +45,11 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
const manifestPath = join(profile, 'package.json')
const manifestBefore = await readFile(manifestPath, 'utf8')
await writeFile(join(profile, 'config'), 'console.log("https://registry.npmjs.org/")\n')
await writeFile(join(profile, '.attempts'), '')
await writeFile(join(profile, 'add'), `
const fs = require('node:fs');
fs.appendFileSync('.attempts', JSON.stringify(process.argv) + '\\n');
console.error("fatal: unable to access 'https://github.com/example/dsh-plugin.git/': Could not resolve host: github.com");
console.error('pnpm must not start after the GitHub connection check fails');
process.exitCode = 1;
`)
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: ZH_BROWSER_LOCALE })
@@ -36,21 +60,25 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
await page.getByRole('button', { name: '添加插件', exact: true }).click()
const spec = 'https://github.com/example/dsh-plugin.git'
const title = failure === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub'
let dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('button', { name: '安装源 默认安装源', exact: true }).waitFor()
await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec)
expect(await page.getByText('无法访问 GitHub', { exact: true }).count()).toBe(0)
expect(await page.getByText(title, { exact: true }).count()).toBe(0)
await dialog.getByRole('button', { name: '安装', exact: true }).click()
dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true })
dialog = page.getByRole('dialog', { name: title, exact: true })
await dialog.waitFor()
expect(await page.getByRole('dialog').count()).toBe(1)
expect(await dialog.getByText('请尝试其他安装来源。', { exact: true }).count()).toBe(1)
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/plugin-install-github/failed.expected.md', import.meta.url)),
fileURLToPath(new URL(`./expected/plugin-install-github/${failure === 'timeout' ? 'timeout' : 'failed'}.expected.md`, import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
const attempts = await readFile(join(profile, '.attempts'), 'utf8')
expect(attempts.trim().split('\n')).toHaveLength(1)
expect(attempts).toBe('')
expect(connections).toBeGreaterThan(0)
await expect.poll(() => sockets.size).toBe(0)
const checked = connections
await dialog.getByRole('button', { name: '改用国内镜像', exact: true }).click()
dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
const input = dialog.getByRole('textbox', { name: '插件包名', exact: true })
@@ -64,12 +92,14 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe(attempts)
expect(connections).toBe(checked)
expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
await input.fill(spec)
await dialog.getByRole('button', { name: '安装', exact: true }).click()
dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true })
dialog = page.getByRole('dialog', { name: title, exact: true })
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await dialog.waitFor({ state: 'detached' })
expect((await readFile(join(profile, '.attempts'), 'utf8')).trim().split('\n')).toHaveLength(2)
expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe('')
expect(connections).toBeGreaterThan(checked)
expect(tripwire.pageErrors).toEqual([])
})
+3
View File
@@ -334,6 +334,8 @@ export interface LaunchOptions {
*/
profile?: {
hmr?: boolean
/** Launcher-owned invocation and environment for profile package operations. */
packageManager?: ProfileContext['packageManager']
packages: { dir: string; enabled?: boolean }[]
/** Additional selected names, including bundles unavailable after an upgrade. */
bundles?: readonly string[]
@@ -772,6 +774,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
profileContext = {
name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
...options.profile?.packageManager === undefined ? {} : { packageManager: options.profile.packageManager },
cwd: workspaceCwd, home: harnessHome,
startedBundles: loadProfileDirectory('dsh', profileDir, INSTALL_ANCHOR).layers.map(layer => layer.packageName),
overlays: processOverlays, telemetryDisabledEnv: undefined,
+65 -2
View File
@@ -1,7 +1,7 @@
/** 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 { createServer, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -45,7 +45,7 @@ it('shows the failed asset, actual download source and recovery advice, then ret
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' })
const alert = page.getByRole('alert')
await alert.waitFor()
expect(await alert.textContent()).toContain('HTTP 503')
expect(await alert.textContent()).toContain(`Download source: ${origin}`)
@@ -59,3 +59,66 @@ it('shows the failed asset, actual download source and recovery advice, then ret
expect(requests).toHaveLength(2)
expect(tripwire.pageErrors).toEqual([])
})
it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0, manual: true }, { winner: 1, manual: true }])(
'uses source $winner with manual selection $manual through the real preparation UI', async ({ winner, manual }) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-voice-mirrors-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
const probes = new Map<number, ServerResponse>(), downloads: number[] = [], requestedPaths: string[] = []
const servers = [0, 1].map(index => createServer((request, response) => {
requestedPaths.push(request.url!)
if (request.method === 'HEAD') {
probes.set(index, response)
// Release the winner only after both real Host requests overlap.
if (probes.size === 2) { const selected = probes.get(winner)!; selected.writeHead(200); selected.end() }
} else {
downloads.push(index)
response.writeHead(index === winner ? 503 : 502); response.end()
}
}))
onTestFinished(async () => {
try { await resources.browser?.close() } finally {
try { await resources.scaffold?.close() } finally {
await Promise.all(servers.map(async (server) => {
const closed = new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
server.closeAllConnections(); await closed
}))
}
}
})
const origins = await Promise.all(servers.map(async (server) => {
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')
return `http://127.0.0.1:${address.port}`
}))
const overlay = join(root, 'voice.patch.yml')
await writeFile(overlay, `- id: speech-to-text-sensevoice\n config: ${JSON.stringify({
dataRoot: join(root, 'models'), modelOrigins: origins, modelProbeTimeoutMs: 10_000,
})}\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()
const picker = page.getByLabel('Model download source', { exact: true })
await picker.waitFor()
if (manual) await picker.selectOption(origins[winner]!)
await page.getByRole('button', { name: 'Download and prepare', exact: true }).click()
const alert = page.getByRole('alert')
await alert.waitFor()
expect(downloads).toEqual(manual ? [winner] : [winner, 1 - winner])
expect(requestedPaths).toHaveLength(manual ? 1 : 4)
expect(requestedPaths.every(path => path === '/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')).toBe(true)
const failedOrigin = origins[manual ? winner : 1 - winner]!
expect(await picker.inputValue()).toBe(manual ? origins[winner] : '')
expect(await alert.textContent()).toContain(manual ? 'HTTP 503' : 'HTTP 502')
expect(await alert.textContent()).toContain(`Download source: ${failedOrigin}`)
await compareOrRefreshGolden(manual ? expected : fileURLToPath(new URL('./expected/voice-download-fallback.expected.md', import.meta.url)),
(await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'),
webSnapshotMode())
expect(tripwire.pageErrors).toEqual([])
})
+9
View File
@@ -40,5 +40,14 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
expect(await page.getByText('Local models will be downloaded to the machine running DSH. No Python or compiler is required.', { exact: true }).count()).toBe(1)
expect(await page.getByText('Audio is recognized on the machine running DSH. If models need downloading, that machine must be able to reach the selected source and its file services. Configure a proxy on that machine if needed.', { exact: true }).count()).toBe(1)
const source = page.getByLabel('Model download source', { exact: true })
expect(await source.inputValue()).toBe('')
expect(await source.getByRole('option').allTextContents()).toEqual(['Automatic (recommended)', 'Hugging Face', 'HF-Mirror (China mirror)'])
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-auto.expected.md', import.meta.url)),
await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode())
await source.selectOption('https://hf-mirror.com')
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-manual.expected.md', import.meta.url)),
await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode())
expect(tripwire.pageErrors).toEqual([])
})
+2 -2
View File
@@ -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/config-catalog.md
config-catalog.md: b9e75f6679d43aef41808a30ed370e26070c1331
config-catalog.zh.md: d76c88fb806998abee414347822a3d11c3b2b7de
config-catalog.md: eda021971f5bb77c5767e9f3a2c3a3c995fae434
config-catalog.zh.md: 02d01b17133c93d9f28626677de88af5497237d9
+11 -5
View File
@@ -1033,8 +1033,12 @@ export interface Config {
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */
modelOrigin?: string | undefined
/** Hugging Face-compatible origins compared before downloading each missing asset. */
modelOrigins: string[]
/** Deadline for concurrent HEAD probes, including redirects to the actual asset. */
modelProbeTimeoutMs: number
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
@@ -2165,16 +2169,18 @@ Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/s
Requires: `loader` · `profileContext`
```ts config-catalog
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -2186,7 +2192,7 @@ export interface Config {
}
```
Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+11 -5
View File
@@ -1035,8 +1035,12 @@ export interface Config {
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */
modelOrigin?: string | undefined
/** Hugging Face-compatible origins compared before downloading each missing asset. */
modelOrigins: string[]
/** Deadline for concurrent HEAD probes, including redirects to the actual asset. */
modelProbeTimeoutMs: number
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
@@ -2167,16 +2171,18 @@ export interface PlanModeConfig {
需要: `loader` · `profileContext`
```ts config-catalog
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -2188,7 +2194,7 @@ export interface Config {
}
```
来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+2 -2
View File
@@ -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/boot.md
boot.md: a1e8917adaf80315d871081740e5a76e5cd1e6dd
boot.zh.md: 1ba455d6ba9ff0053ff9d6f6d1313e7ae871a2a1
boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7
boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4
+4 -2
View File
@@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle.
@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle.
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
+4 -2
View File
@@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle.
@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle.
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
+2 -2
View File
@@ -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: 1c128b268c8fbbcabc13f4ca52daaa62534a6366
voice-input.zh.md: 5ce0626f4dc769cb12a842b7ad296eb915cb94ea
voice-input.md: 1d85e8226afddad187533cfe5b14b6750d46c158
voice-input.zh.md: 78a70ab9c265df86c1d99aa863a4be0ae1abbd9e
+6 -2
View File
@@ -18,6 +18,8 @@ The input facade captures a revision-bearing selection before recording. `InputA
## Preparation and settings
`SpeechProviderInfo.downloadSources` advertises preparation origins. `SpeechPreparationOptions.downloadSource` selects one for a task; omission preserves provider policy. SenseVoice validates the advertised choice, pins a manual source without fallback, and rejects source changes during active preparation. The UI keeps a choice for retries in the current card; it is not a persisted recognition preference.
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.
@@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent.
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
* @param options - task-local source selection validated by the provider.
*/
@Remote prepare(providerId: SpeechProviderId): void
@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel resource preparation.
@@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
* @param options - task-local source selection validated by the provider.
*/
prepare(id: SpeechProviderId): void
prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
+6 -2
View File
@@ -18,6 +18,8 @@
## 准备与设置
`SpeechProviderInfo.downloadSources` 公布准备阶段的源。`SpeechPreparationOptions.downloadSource` 为单次任务选择一个源;省略时保留提供方策略。SenseVoice 校验公布的选项,固定使用手动指定的源而不回退,并拒绝在准备期间改源。UI 为当前卡片中的重试保留选择,但不会将其保存为识别偏好。
Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、安装引导弹窗、Bundle 详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。
准备失败时可包含 `SpeechDownloadFailure`,提供文件、下载源、原因分类以及可选错误码或 HTTP 状态。Client 将恢复建议本地化;原始下载错误留在 Host。
@@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent.
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
* @param options - task-local source selection validated by the provider.
*/
@Remote prepare(providerId: SpeechProviderId): void
@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel resource preparation.
@@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise<void>
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
* @param options - task-local source selection validated by the provider.
*/
prepare(id: SpeechProviderId): void
prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void
/**
* Explicitly cancel provider preparation without tying it to a browser connection.
@@ -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/boot/plugin-manager/README.md
README.md: 1de9c9d78e421d44eb006f1ab7fd82656f006a2a
README.zh.md: f7502aab64738ad1feccf58b84ff945213df2eff
README.md: 803566c8650df7186c5344945ea0fe2359e8e70c
README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca
+4 -1
View File
@@ -45,11 +45,13 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en
`inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation.
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries.
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
`waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation.
@@ -61,6 +63,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
|---|---|---|
| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. |
| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. |
| `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
+4 -1
View File
@@ -45,11 +45,13 @@ kind: "package-reference"
`inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
`waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。
@@ -61,6 +63,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
|---|---|---|
| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 |
| `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
@@ -0,0 +1,68 @@
/** Bounded GitHub repository checks using the installer's Git configuration and environment. */
import { mkdir, mkdtemp, open } from 'node:fs/promises'
import { join } from 'node:path'
import { execa } from 'execa'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { classifyInstallFailure } from './install-failure.ts'
import type { ParsedInstallSpec } from './install-spec.ts'
import type { PackageResult } from './types.ts'
/** Deadline, diagnostics and cancellation for one read-only GitHub check. */
export interface GithubConnectionOptions {
timeoutMs: number
outputBytes: number
signal: AbortSignal
/** The application-owned package manager's environment, also used by its Git children. */
env?: Readonly<Record<string, string>>
}
/**
* Check a GitHub repository before pnpm starts, without downloading or building its package.
* Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting.
* Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback.
* @param spec The parsed installation address; npm packages, local paths and other hosts are not checked.
* @param dir The profile directory where installation runs.
* @param options The connection deadline, output bound and operation cancellation.
* @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec.
*/
export async function checkGithubConnection(
spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions,
): Promise<PackageResult | undefined> {
if (spec.kind !== 'git') return undefined
const host = spec.host.toLowerCase().replace(/:.*$/, '')
if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined
const repository = spec.spec.replace(/#.*$/s, '')
.replace(/^github:/i, 'https://github.com/')
.replace(/^gist:/i, 'https://gist.github.com/')
.replace(/^git\+/i, '')
const logRoot = join(dir, '.plugin-manager', 'logs')
await mkdir(logRoot, { recursive: true, mode: 0o700 })
const logDir = await mkdtemp(join(logRoot, 'github-connection-'))
const logPath = join(logDir, 'git.log')
const log = await open(logPath, 'ax+', 0o600)
try {
const result = await execa('git', ['-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'], {
cwd: dir,
env: {
...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never',
},
extendEnv: false,
stdin: 'ignore', stdout: 'ignore', stderr: { file: logPath, append: true }, buffer: false, reject: false,
timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL',
})
if (!result.failed) return undefined
if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`)
if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed')
const { size } = await log.stat()
const bytes = Buffer.alloc(Math.min(size, options.outputBytes))
await log.read(bytes, 0, bytes.length, size - bytes.length)
const output = bytes.toString('utf8')
return {
exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), output, truncated: size > options.outputBytes, logPath,
kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }),
}
} finally {
await log.close()
}
}
+31 -8
View File
@@ -24,6 +24,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan }
import { writePluginEnabled } from './patch.ts'
import { ManagementFailure } from './failure.ts'
import { approveBuilds, readPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange,
PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
@@ -33,16 +34,18 @@ export type * from './types.ts'
export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -172,6 +175,7 @@ export class PluginManager extends TypertRemoteService {
outputBytes: z.number().step(1).min(1).default(16384),
lockWaitMs: z.number().step(1).min(0).default(120000),
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
registry: z.string().pattern(REGISTRY_URL),
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
})
@@ -183,6 +187,7 @@ export class PluginManager extends TypertRemoteService {
private readonly outputBytes: number
private readonly lockWaitMs: number
private readonly inspectTimeoutMs: number
private readonly githubConnectionTimeoutMs: number
private readonly pnpmCommand: string
private readonly configuredRegistries: Omit<PluginRegistries, 'resolved'>
private readonly ownerContext: Context
@@ -199,6 +204,7 @@ export class PluginManager extends TypertRemoteService {
this.outputBytes = (config as Required<Config>).outputBytes
this.lockWaitMs = (config as Required<Config>).lockWaitMs
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
this.pnpmCommand = (config as Required<Config>).pnpmCommand
this.configuredRegistries = {
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
@@ -406,7 +412,9 @@ export class PluginManager extends TypertRemoteService {
}
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -418,7 +426,7 @@ export class PluginManager extends TypertRemoteService {
installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
const requestId = options?.requestId
const control: InstallControl = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) }
const stopped = (): boolean => control.abort.signal.aborted
const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted
if (requestId !== undefined) this.installs.set(requestId, control)
const announce = (phase: PluginInstallProgress['phase'], attempt?: PluginInstallProgress['attempt']): void => {
if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } })
@@ -432,12 +440,27 @@ export class PluginManager extends TypertRemoteService {
}
const files = await this.readRestoredFiles()
const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
const plan = registryPlan(options?.registry, await this.registries())
let name: string
try {
// The last run is the result's; the registries asked stay listed whatever the outcome.
let run: PackageResult | undefined
result.registries = []
const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes,
signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env },
})
this.packageOperations.add(connection)
let connectionFailure: PackageResult | undefined
try { connectionFailure = await connection }
finally { this.packageOperations.delete(connection) }
if (stopped()) throw new InstallCancelledError()
if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') {
result.packageResult = connectionFailure
result.failedAt = 'spec-host'
throw new Error(connectionFailure.output)
}
// The last run is the result's; the registries asked stay listed whatever the outcome.
const plan = registryPlan(options?.registry, await this.registries())
let run: PackageResult | undefined
for (const [index, registry] of plan.entries()) {
if (index > 0) await this.restoreFiles(files)
// A stop that landed while the files went back, or before the first run, starts no run with a dead signal.
@@ -509,7 +532,7 @@ export class PluginManager extends TypertRemoteService {
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote
@@ -25,6 +25,7 @@ const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [
['disk-full', /\bENOSPC\b|no space left on device/i],
['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
['unknown', /The requested URL returned error: 40[134]\b/],
['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/],
]
+2 -2
View File
@@ -69,7 +69,7 @@ export interface PluginRegistries {
readonly resolved: string | null
}
/** How a pnpm run failed, read off how it ended and what it printed. */
/** How a package operation failed, read off how it ended and what it printed. */
export type PluginInstallFailureKind =
| 'pnpm-missing'
| 'timeout'
@@ -82,7 +82,7 @@ export type PluginInstallFailureKind =
| 'integrity'
| 'unknown'
/** Pnpm completion, including a retrieval path for unabridged diagnostics. */
/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */
export interface PackageResult {
exitCode: number
output: string
@@ -0,0 +1,183 @@
/** Real Git transport deadlines, process cleanup, and bounded connection diagnostics. */
import { once } from 'node:events'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { appendFileSync } from 'node:fs'
import { createServer, type Socket } from 'node:net'
import { createServer as createHttpServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as subprocess from 'execa'
import { expect, it, onTestFinished, vi } from 'vitest'
import { checkGithubConnection } from '../src/github-connection.ts'
import { parseInstallSpec } from '../src/install-spec.ts'
vi.mock('execa', async (original) => {
const module = await original<typeof import('execa')>()
return { ...module, execa: vi.fn(module.execa) }
})
async function fixture() {
const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
const controller = new AbortController()
const tasks: Promise<unknown>[] = []
onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) })
const config = join(dir, 'git.config')
await writeFile(config, '')
const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }
const check = (spec: string, timeoutMs = 3000, outputBytes = 4096) => {
const task = checkGithubConnection(parseInstallSpec(spec), dir, {
timeoutMs, outputBytes, signal: controller.signal,
env,
})
tasks.push(task)
return task
}
return { dir, config, controller, check, env }
}
async function proxy(config: string, fail: boolean) {
const sockets = new Set<Socket>()
const connected = Promise.withResolvers<undefined>()
const disconnected = Promise.withResolvers<undefined>()
const server = createServer((socket) => {
sockets.add(socket)
socket.on('error', () => {})
socket.once('close', () => { sockets.delete(socket); disconnected.resolve(undefined) })
socket.once('data', () => { connected.resolve(undefined); if (fail) socket.destroy() })
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
await writeFile(config, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`)
return { connected: connected.promise, disconnected: disconnected.promise, sockets }
}
it('leaves registry packages and other Git hosts to the installer', async () => {
const { check, dir } = await fixture()
expect(await check('@acme/plugin')).toBeUndefined()
expect(await check('https://gitlab.com/acme/plugin.git')).toBeUndefined()
await expect(readFile(join(dir, '.plugin-manager', 'logs'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each([
['https://github.com/acme/plugin.git#main', 'https://github.com/'],
['github:acme/plugin#main', 'https://github.com/'],
['git+https://github.com/acme/plugin.git#main', 'https://github.com/'],
['git+ssh://[email protected]:22/acme/plugin.git#main', 'ssh://[email protected]:22/'],
['[email protected]:acme/plugin.git#main', '[email protected]:'],
['gist:acme/plugin#main', 'https://gist.github.com/'],
])('checks %s through the profile Git configuration without downloading a package', async (spec, prefix) => {
const { check, dir, config } = await fixture()
const repository = join(dir, 'empty.git')
await subprocess.execa('git', ['init', '--bare', repository])
// Match both forms exactly; a fragment reaching Git would fail to resolve this repository.
await writeFile(config, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ${prefix}acme/plugin.git\n insteadOf = ${prefix}acme/plugin\n`)
expect(await check(spec)).toBeUndefined()
})
it('reports an immediate transport failure without waiting for the deadline', async () => {
const { check, config } = await fixture()
const transport = await proxy(config, true)
const result = await check('https://github.com/acme/plugin.git')
await transport.connected
expect(result).toMatchObject({ exitCode: 128, kind: 'network', truncated: false })
expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output)
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it('ends a silent proxy connection and its Git child at the connection deadline', async () => {
const { check, config } = await fixture()
const transport = await proxy(config, false)
const running = check('https://github.com/acme/plugin.git')
await transport.connected
const result = await running
expect(result?.kind).toBe('timeout')
expect(result!.output).toContain('connection to github.com timed out after 3000ms')
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it('cancels a connected Git probe and closes its transport before the deadline', async () => {
const { check, config, controller } = await fixture()
const transport = await proxy(config, false)
const running = check('https://github.com/acme/plugin.git', 60000)
await transport.connected
controller.abort()
const result = await running
expect(result?.kind).toBe('unknown')
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it.each([undefined, 'Git could not start'])('retains a diagnostic when Git writes no stderr (%s)', async (shortMessage) => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited<ReturnType<typeof subprocess.execa>>)
onTestFinished(() => { run.mockRestore() })
const result = await check('github:acme/plugin')
expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' })
expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output)
})
it('bounds returned diagnostics while retaining Git stderr in the operation log', async () => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockImplementation((...args: unknown[]) => {
const options = args[2] as { stderr: { file: string } }
appendFileSync(options.stderr.file, '0123456789')
return Promise.resolve({ failed: true, exitCode: 128 }) as ReturnType<typeof subprocess.execa>
})
onTestFinished(() => { run.mockRestore() })
const result = await check('github:acme/plugin', 3000, 4)
expect(result).toMatchObject({ output: '6789', truncated: true })
expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789')
})
it('reports a missing Git executable without classifying it as a network failure', async () => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, code: 'ENOENT', shortMessage: 'spawn git ENOENT' } as Awaited<ReturnType<typeof subprocess.execa>>)
onTestFinished(() => { run.mockRestore() })
expect(await check('github:acme/plugin')).toMatchObject({ exitCode: 127, kind: 'unknown', output: 'spawn git ENOENT' })
})
it('does not invoke configured credential helpers or askpass for an authentication challenge', async () => {
const { check, dir, config, env } = await fixture()
const server = createHttpServer((_request, response) => {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
response.end()
})
onTestFinished(async () => {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port')
const marker = join(dir, 'prompted')
const helper = join(dir, 'credential-helper.cjs')
await writeFile(helper, `require('node:fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n')`)
const helperCommand = `"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"`
const askpass = join(dir, 'askpass.sh')
await writeFile(askpass, `#!/bin/sh\nexec ${helperCommand} "$@"\n`, { mode: 0o700 })
await writeFile(config, `[url "http://127.0.0.1:${String(address.port)}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`)
await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!${helperCommand}`])
await subprocess.execa('git', ['config', '--file', config, 'core.askPass', askpass])
Object.assign(env, { GIT_ASKPASS: askpass, SSH_ASKPASS: askpass })
// The same Git configuration really prompts without the probe's noninteractive overrides.
await subprocess.execa('git', ['ls-remote', '--', 'https://github.com/acme/private.git', 'HEAD'], {
cwd: dir, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, reject: false,
})
const prompted = await readFile(marker, 'utf8')
expect(prompted).toContain('get')
expect(prompted).toContain('Username')
await rm(marker)
expect(await check('github:acme/private.git')).toMatchObject({ exitCode: 128, kind: 'unknown' })
await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' })
})
@@ -56,6 +56,10 @@ describe('parseInstallSpec', () => {
})
describe('classifyInstallFailure', () => {
it.each([401, 403, 404])('leaves Git HTTP %s responses out of connection recovery', (status) => {
expect(classifyInstallFailure({ log: `fatal: unable to access 'https://github.com/acme/private.git/': The requested URL returned error: ${String(status)}` })).toBe('unknown')
})
it('names the run\'s end before reading its output, then the most specific code in the output', () => {
expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout')
expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing')
@@ -1,9 +1,13 @@
/** Persistent manager behavior through a real profile Include and Loader. */
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { once } from 'node:events'
import { createServer } from 'node:http'
import type { Socket } from 'node:net'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import type { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import {
@@ -17,6 +21,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { Group } from '@deepseek-ai/cordis-plugin-loader'
import * as operations from '../src/operations.ts'
import * as githubConnection from '../src/github-connection.ts'
import { parse, parseDocument } from 'yaml'
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
@@ -60,7 +65,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
})
// What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry.
const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
onTestFinished(() => { registry.mockRestore() })
const connection = vi.spyOn(githubConnection, 'checkGithubConnection').mockResolvedValue(undefined)
onTestFinished(() => { registry.mockRestore(); connection.mockRestore() })
let stopHmr = async () => {}
if (reload === 'live') {
await ctx.plugin(Timer)
@@ -68,9 +74,155 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
stopHmr = () => owner.dispose()
await ctx.hmr.runExclusive(async () => {})
}
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays }
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection }
}
it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => {
const { manager, dir, connection } = await fixture()
const before = readFileSync(join(dir, 'package.json'), 'utf8')
const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind }
connection.mockResolvedValue(failure)
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({
application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure,
})
expect(pnpm).not.toHaveBeenCalled()
expect(connection).toHaveBeenCalledWith(expect.objectContaining({ host: 'github.com' }), dir, expect.objectContaining({ timeoutMs: 5000 }))
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
})
it('leaves non-network GitHub errors to pnpm and forwards profile Git environment and deadline', async () => {
const env = { GIT_CONFIG_GLOBAL: '/application/git.config' }
const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env })
connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' })
const failure = { exitCode: 1, output: 'pnpm owns authentication', truncated: false, logPath: 'pnpm.log', kind: 'unknown' as const }
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue(failure)
onTestFinished(() => { pnpm.mockRestore() })
const result = await manager.installBundle('github:acme/private-plugin')
expect(result.application).toBe('failed')
expect(result.packageResult).toEqual(failure)
expect(pnpm).toHaveBeenCalledOnce()
expect(result.failedAt).toBeUndefined()
expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env }))
})
it('cancels an active GitHub check before starting pnpm', async () => {
const { manager, connection } = await fixture()
const entered = Promise.withResolvers<undefined>()
connection.mockImplementation(async (_spec, _dir, options) => {
entered.resolve(undefined)
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' }
})
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId
const installing = manager.installBundle('github:acme/dsh-plugin', { requestId })
await entered.promise
expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(pnpm).not.toHaveBeenCalled()
})
it('waits for the GitHub check before installing and activating the bundle', async () => {
const { manager, dir, bundle, connection } = await fixture()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
connection.mockImplementation(async () => { entered.resolve(undefined); await release.promise; return undefined })
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
bundle('addon', [])
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
})
const installing = manager.installBundle('github:acme/addon')
onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() })
await entered.promise
expect(pnpm).not.toHaveBeenCalled()
release.resolve(undefined)
expect(await installing).toMatchObject({ application: 'applied', changed: true, bundle: 'addon' })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon')
})
it('disposal waits for an active GitHub check to stop', async () => {
const { ctx, manager, connection } = await fixture('startup')
const entered = Promise.withResolvers<undefined>()
const released = Promise.withResolvers<undefined>()
connection.mockImplementation(async (_spec, _dir, options) => {
entered.resolve(undefined)
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
released.resolve(undefined)
return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' }
})
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
const installing = manager.installBundle('github:acme/addon')
await entered.promise
await ctx.fiber.dispose()
await released.promise
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(pnpm).not.toHaveBeenCalled()
})
it.each(['github:acme/connected', 'https://github.com/acme/connected.git', 'git+ssh://[email protected]/acme/connected.git'])(
'installs %s through real Git and pnpm with private repository SSH fallback', async (spec) => {
const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url))
const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' }
const { manager, dir, connection } = await fixture('startup', false, undefined, {}, {
command: process.execPath, args: ['--expose-internals', pnpm], env,
})
connection.mockRestore()
const repository = join(dir, 'repository')
mkdirSync(repository)
env.GIT_CONFIG_GLOBAL = join(dir, 'git.config')
writeFileSync(env.GIT_CONFIG_GLOBAL, '')
const git = (args: string[]) => execa('git', args, { cwd: repository, env })
await git(['init', '--initial-branch=main'])
const name = '@test/github-connected'
writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n')
await git(['add', '.'])
await git(['-c', 'user.name=Fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle'])
const requests: string[] = []
const sockets = new Set<Socket>()
const proxy = createServer((_request, response) => {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
response.end()
})
proxy.on('connection', (socket) => {
sockets.add(socket)
socket.once('close', () => { sockets.delete(socket) })
})
proxy.on('connect', (request, socket) => {
requests.push(request.url ?? '')
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) })
})
proxy.listen(0, '127.0.0.1')
await once(proxy, 'listening')
const address = proxy.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
const proxyUrl = `http://127.0.0.1:${String(address.port)}`
writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://[email protected]/acme/connected.git\n insteadOf = [email protected]:acme/connected.git\n[url "${proxyUrl}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`)
// pnpm probes HTTPS with Node before falling back to SSH, even for an SSH spec.
// Route that HTTP request to our proxy too; Git's URL rewrite alone cannot isolate it.
const manifest = readProfileManifest('test', dir)
delete manifest.dependencies
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\nhttps-proxy=${proxyUrl}\nproxy=${proxyUrl}\nnoproxy=\n`)
const result = await manager.installBundle(spec, { enabled: false })
expect(result.error).toBeUndefined()
expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } })
expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name)
expect(requests).toContain('github.com:443')
},
)
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => {
const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => {
@@ -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/client/ui-plugin-manager/README.md
README.md: d89e56a1115d077865c49150ecbf4991a524cfe9
README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5
README.md: ceedcd69eabb90a59db99bcc244cff7bb1a98c84
README.zh.md: f3c3d9e922dcd6bf8283287fb0e4aa9970fe390d
+1 -1
View File
@@ -39,7 +39,7 @@ The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog.
When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub** and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself.
When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub**, or **GitHub connection timed out** for a timeout, and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself.
### Switching a bundle
@@ -39,7 +39,7 @@ Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。
Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。
Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,超时时显示**连接 GitHub 超时**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。
### 切换一个组合包
@@ -785,7 +785,7 @@ function InstallDialog({
<Modal
open={install.open}
onClose={onClose}
title={t('installGithubFailedTitle')}
title={t(install.failure?.kind === 'timeout' ? 'installGithubTimeoutTitle' : 'installGithubFailedTitle')}
closeLabel={t('close')}
description={t('installGithubFailedDescription')}
footer={(
@@ -88,6 +88,7 @@ export const zh = {
installedTitle: '已安装',
installFailedTitle: '插件安装失败',
installGithubFailedTitle: '无法访问 GitHub',
installGithubTimeoutTitle: '连接 GitHub 超时',
installGithubFailedDescription: '请尝试其他安装来源。',
installUseGithubMirror: '改用国内镜像',
installPackageLabel: '插件包名',
@@ -276,6 +277,7 @@ export const en = {
installedTitle: 'Installed',
installFailedTitle: 'The plugin could not be installed',
installGithubFailedTitle: 'Cannot access GitHub',
installGithubTimeoutTitle: 'GitHub connection timed out',
installGithubFailedDescription: 'Try another installation source.',
installUseGithubMirror: 'Use mainland China mirror',
installPackageLabel: 'Plugin package name',
@@ -1057,16 +1057,16 @@ describe('PluginManagerPage', () => {
expect(screen.getByText(en.installSubjectTarball)).toBeTruthy()
})
it('shows GitHub recovery only after a connection failure and returns to package input', () => {
it.each(['network', 'timeout'] as const)('shows GitHub recovery only after a %s failure and returns to package input', (kind) => {
const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' } as const
const failed: InstallState = {
...IDLE_INSTALL, open: true, spec: subject.spec, registries: REGISTRIES, phase: 'failed', subject,
failure: { reason: 'Could not resolve host: github.com', kind: 'network', failedAt: 'spec-host' },
failure: { reason: 'GitHub connection failed', kind, failedAt: 'spec-host' },
}
const { actions, set, setLanguage } = renderTab({ install: { ...failed, phase: 'idle', failure: null } })
expect(screen.queryByText(en.installGithubFailedTitle)).toBeNull()
set({ install: failed })
const dialog = screen.getByRole('dialog', { name: en.installGithubFailedTitle })
const dialog = screen.getByRole('dialog', { name: kind === 'timeout' ? en.installGithubTimeoutTitle : en.installGithubFailedTitle })
expect(within(dialog).getByText(en.installGithubFailedDescription)).toBeTruthy()
expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull()
fireEvent.click(within(dialog).getByRole('button', { name: en.installUseGithubMirror }))
@@ -1075,7 +1075,7 @@ describe('PluginManagerPage', () => {
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(actions.closeInstall).toHaveBeenCalledOnce()
setLanguage(zh)
expect(screen.getByRole('dialog', { name: '无法访问 GitHub' })).toBeTruthy()
expect(screen.getByRole('dialog', { name: kind === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' })).toBeTruthy()
expect(screen.getByText('请尝试其他安装来源。')).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, mirrorRecovery: true, registries: REGISTRIES, registry: { kind: 'offered', registry: MIRROR } } })
const form = within(screen.getByRole('dialog', { name: zh.installTitle }))
@@ -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/client/ui-settings-models/README.md
README.md: 0d6f34436f11eadd84ff1c2d6a7731aee1a42f32
README.zh.md: f28744f46eda015528553e1b363e1427d2b927ae
README.md: 52a8e91c158db912a5c7a4f84ff2892de2bd4a7d
README.zh.md: 9470c9c0f1e80136e31e6e656b09c5fd17f7c691
+1 -1
View File
@@ -45,7 +45,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid
### Adding and deleting providers
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its name, falling back to its id when no name is supplied. Rows stay on one line; hovering reveals the full text when it is truncated. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
**Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
### First-run dialogs
@@ -45,7 +45,7 @@ Host 配置 `credentialOnboarding` 默认为 `true`。Electron preload 标记会
### 新增与删除提供商
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项显示模型名称,未提供名称时回退到 id。每行保持单行,文字截断时可悬停查看完整内容。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
**添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。
### 首次运行弹窗
@@ -447,8 +447,8 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
checked={picked.has(candidate.id)}
onChange={() => { toggle(candidate.id) }}
/>
<span className={styles['candidateName']} title={candidate.name ?? candidate.id}>
{candidate.name ?? candidate.id}
<span className={styles['candidateId']} title={candidate.name ?? candidate.id}>
{candidate.id}
</span>
</label>
</li>
@@ -705,9 +705,10 @@ select.input {
cursor: pointer;
}
.candidateName {
.candidateId {
flex: 1 1 auto;
min-width: 0;
font-family: var(--ds-font-family-code);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
@@ -609,8 +609,8 @@ describe('endpoint interrogation', () => {
fireEvent.click(screen.getByText(en.fetchModels))
await screen.findByText(en.fetchTitle)
expect(screen.getByRole<HTMLInputElement>('checkbox', { name: 'Fresh' }).checked).toBe(true)
expect(screen.queryByRole('checkbox', { name: 'fresh' })).toBeNull()
expect(screen.getByRole<HTMLInputElement>('checkbox', { name: 'fresh' }).checked).toBe(true)
expect(screen.queryByRole('checkbox', { name: 'Fresh' })).toBeNull()
// The already-configured row starts unchecked; the new one starts checked.
const boxes = [...document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
expect(boxes.map(box => box.checked)).toEqual([false, true])
@@ -750,8 +750,8 @@ describe('endpoint interrogation', () => {
fireEvent.click(screen.getByText(en.fetchModels))
const dialog = await screen.findByRole('dialog')
const search = screen.getByLabelText<HTMLInputElement>(en.fetchSearch)
expect(dialog.textContent).toContain('Beta Display')
expect(dialog.textContent).not.toContain('opaque-id')
expect(dialog.textContent).toContain('opaque-id')
expect(dialog.textContent).not.toContain('Beta Display')
expect([...dialog.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')]
.map(box => box.checked)).toEqual([true, true, true])
@@ -760,11 +760,11 @@ describe('endpoint interrogation', () => {
expect(dialog.textContent).not.toContain('opaque-id')
fireEvent.change(search, { target: { value: 'beta' } })
expect(dialog.textContent).toContain('Beta Display')
expect(dialog.textContent).toContain('opaque-id')
expect(dialog.textContent).not.toContain('alpha')
fireEvent.change(search, { target: { value: 'opaque' } })
expect(dialog.textContent).toContain('Beta Display')
expect(dialog.textContent).toContain('opaque-id')
expect(dialog.textContent).not.toContain('alpha')
fireEvent.click(within_(dialog, en.fetchDeselectAll))
@@ -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/api-speech-to-text/README.md
README.md: ecc83c15b4217323c074733ae77259261598803c
README.zh.md: 46ac6a16c902c32f2effb23a98141b1afbdccd37
README.md: 113c1f94459329051182ab2503b5220ffa9ad50b
README.zh.md: bc853bd00da978a42d56dcbefcd98109efc78491
@@ -27,6 +27,8 @@ The `speech` Remote connects browser recordings to `ctx.speechToText`. It expose
Compose with the speech Service Definition and Typert. `maxAudioBytes` and `maxDurationSeconds` limit accepted recordings. The browser UI mounts this package’s generated `/remote` contribution when enabled.
`prepare(providerId, { downloadSource })` forwards one advertised source to the provider; omission keeps its configured policy. The catalog carries `downloadSources` for the picker. The provider rejects unavailable choices and changes to an active task’s source.
-----
<a id="understand-the-implementation"></a>
@@ -27,6 +27,8 @@ kind: "package-reference"
与语音服务定义和 Typert 组合。`maxAudioBytes` 与 `maxDurationSeconds` 限制接收的录音。浏览器 UI 启用时挂载本包生成的 `/remote` 贡献。
`prepare(providerId, { downloadSource })` 将公布的一个下载源转发给提供方,省略时保留其配置策略。目录通过 `downloadSources` 提供选择器选项。提供方拒绝不可用的源,以及对进行中任务的源变更。
-----
<a id="understand-the-implementation"></a>
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-experimental-speech-to-text'
import type { SpeechProviderId, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { SpeechPreparationOptions, SpeechProviderId, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { SpeechCatalog, TranscriptionRequest } from './types.ts'
import { validateWave } from '@deepseek-ai/dsh-experimental-speech-to-text/wave'
@@ -66,9 +66,10 @@ export default class SpeechController extends TypertRemoteService {
/**
* Start or join one Host-owned preparation task.
* @param providerId - selected recognizer.
* @param options - task-local source selection validated by the provider.
*/
@Remote
prepare(providerId: SpeechProviderId): void { this.ctx.speechToText.prepare(providerId) }
prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void { this.ctx.speechToText.prepare(providerId, options) }
/**
* Explicitly cancel resource preparation.
@@ -39,6 +39,20 @@ it('lists limits without inference and forwards explicit provider and language',
expect(recognize).toHaveBeenLastCalledWith(expect.objectContaining({ language: 'auto' }), expect.any(AbortSignal))
})
it('forwards an explicit download source to the registered preparation owner', async () => {
const ctx = new Context(); roots.push(ctx)
const speech = new SpeechToText(ctx, SpeechToText.Config({ defaultProvider: id }))
const prepare = vi.fn()
speech.register({ info: { id, name: 'local', location: 'host-local', languages: ['auto'], downloadSources: ['https://hf-mirror.com'] },
preparation: { snapshot: () => ({ phase: 'unprepared' }), subscribe: () => () => {}, prepare, cancel: async () => {} },
transcribe: async () => ({ text: '', audioSeconds: 0, inferenceSeconds: 0 }),
})
const api = new SpeechController(ctx, { maxAudioBytes: 32044, maxDurationSeconds: 1 })
expect(api.catalog().providers[0]?.downloadSources).toEqual(['https://hf-mirror.com'])
api.prepare(id, { downloadSource: 'https://hf-mirror.com' })
expect(prepare).toHaveBeenCalledWith({ downloadSource: 'https://hf-mirror.com' })
})
it('rejects oversized, noncanonical, malformed and overlong audio without inference', async () => {
const { api, recognize } = fixture()
for (const audioBase64 of ['x'.repeat(50000), 'AAAA?', '', 'QQ==', recording() + '\n']) {
@@ -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/client-ui-voice-input/README.md
README.md: 4d859f4d9837c249c564abfbba0682f1493502d9
README.zh.md: 84a02e4f620feb35706de5b3bc10ec291f32c423
README.md: 764962ff496c9fdd91073c2aa391ebceeec23608
README.zh.md: 105cb641684eb2920c04294e74f1b5389f564c0c
@@ -27,6 +27,8 @@ This optional browser plugin adds an outline microphone icon between the model s
Enable the [voice input bundle](../voice-input-bundle/README.md) from Plugins. After cache inspection, missing models trigger a setup prompt with Go to setup and Later actions. Go to setup opens bundle details; Download and prepare starts installation there. Intact cached models need no prompt or repeated installation. Before preparation, local providers can show their estimated disk, memory and setup-time requirements. The preparation summary starts collapsed; expand it to inspect completed, current and pending steps. Once ready, click the microphone, allow access and click Stop to transcribe. Cancel or Escape discards capture. Recognition feedback stays inside the toolbar. Provider and language preferences are saved from bundle details through the Settings service. Browser microphone access requires HTTPS or loopback and operating-system permission.
Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready.
-----
<a id="understand-the-implementation"></a>
@@ -27,6 +27,8 @@ kind: "package-reference"
在插件管理页启用[语音输入 Bundle](../voice-input-bundle/README.zh.md)。缓存检查后,缺少模型时弹出安装引导,可选择“前往安装”或“稍后”。“前往安装”打开 Bundle 详情,再点击“下载并准备”才开始安装。缓存完整时不弹引导,也无需重复安装。准备前,本地 Provider 可展示磁盘、内存与安装时间的估算。准备摘要默认折叠;展开后可查看已完成、当前和未开始的步骤。就绪后点击麦克风,允许访问,再点击停止以转写。取消或 Escape 丢弃录音。识别反馈留在工具栏内部。Provider 和语言偏好在 Bundle 详情中通过 Settings 服务保存。浏览器麦克风访问需要 HTTPS 或环回地址,以及操作系统权限。
下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。
-----
<a id="understand-the-implementation"></a>
@@ -44,6 +44,11 @@ function PreparationFailure({ state, t }: { state: Extract<SpeechPreparationStat
export function PreparationCard({ provider, connected, prepare, cancelPreparation, t }: PreparationCardProps) {
const state = provider.preparation
const [expanded, setExpanded] = useState(false), [now, setNow] = useState(Date.now), [error, setError] = useState('')
const [source, setSource] = useState(''), [submitting, setSubmitting] = useState(false)
const sources = provider.downloadSources ?? []
const [firstSource = ''] = sources
const selectedSource = sources.includes(source) ? source : sources.length === 1 ? firstSource : ''
const canPrepare = ['unprepared', 'cancelled', 'failed'].includes(state.phase)
const current = state.steps?.find(step => step.status === 'running' || step.status === 'failed' || step.status === 'cancelled')
const preparing = preparationTone(state) === 'ongoing'
const startedAt = current?.startedAt ?? ('startedAt' in state ? state.startedAt : undefined)
@@ -53,8 +58,9 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio
return () => { clearInterval(timer) }
}, [preparing, startedAt])
const run = async (action: () => Promise<unknown>): Promise<void> => {
setError('')
setError(''); setSubmitting(true)
try { await action() } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) }
finally { setSubmitting(false) }
}
const metric = state.phase === 'downloading' ? byteText(state, t)
: preparing && startedAt !== undefined ? t('elapsed', { seconds: String(Math.max(0, Math.floor((now - startedAt) / 1000))) }) : ''
@@ -100,10 +106,21 @@ export function PreparationCard({ provider, connected, prepare, cancelPreparatio
</DisclosureRow>
{!expanded && <DownloadProgress state={state} t={t} />}
{state.phase === 'failed' && <PreparationFailure state={state} t={t} />}
{canPrepare && sources.length > 0 && <div className={css.sourceChoice}>
<label>{t('sourceChoice')}<select aria-label={t('sourceChoice')} value={selectedSource} disabled={!connected || submitting || sources.length === 1}
onChange={(event) => { setSource(event.target.value) }}>
{sources.length > 1 && <option value="">{t('sourceAuto')}</option>}
{sources.map(origin => <option key={origin} value={origin}>
{origin === 'https://huggingface.co' ? t('sourceHuggingFace')
: origin === 'https://hf-mirror.com' ? t('sourceMirror') : origin}
</option>)}
</select></label>
<small className={css.metric}>{t(selectedSource === '' ? 'sourceAutoHelp' : 'sourceManualHelp')}</small>
</div>}
<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>}
{preparing && state.phase !== 'waking' && <Button variant="ghost" size="sm" disabled={!connected || state.phase === 'cancelling'}
{canPrepare && <Button variant="outline" size="sm" disabled={!connected || submitting}
onClick={() => { void run(() => selectedSource === '' ? prepare(provider.id) : prepare(provider.id, { downloadSource: selectedSource })) }}>{t(state.phase === 'unprepared' ? 'prepare' : 'retryPrepare')}</Button>}
{preparing && state.phase !== 'waking' && <Button variant="ghost" size="sm" disabled={!connected || submitting || state.phase === 'cancelling'}
onClick={() => { void run(() => cancelPreparation(provider.id)) }}>{t('cancelPrepare')}</Button>}
</div>
{error && <p className={css.preparationError} role="alert">{t('failed', { message: error })}</p>}
@@ -25,8 +25,9 @@
.stepProgress { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
.stepProgress small { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
.preferences { display: grid; gap: 12px; font-size: 13px; margin: 18px 0; }
.preferences label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; }
.preferences select { border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; }
.preferences label, .sourceChoice label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; }
.preferences select, .sourceChoice select { border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; }
.sourceChoice { display: grid; gap: 6px; }
.preferences p { margin: 0; }
.settingsCard { list-style: none; padding: 16px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; }
.settingsCard > p { font-size: 13px; color: var(--dsw-alias-label-secondary); margin: 8px 0 0; }
@@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TranscriptionRequest } from '@deepseek-ai/dsh-experimental-api-speech-to-text/types'
import type { SpeechProviderId, SpeechSelection, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { SpeechPreparationOptions, SpeechProviderId, SpeechSelection, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { RecordingError, audioBase64, type Recording } from './audio.ts'
import type { SpeechReadiness } from './readiness.ts'
@@ -17,7 +17,7 @@ export interface VoiceInputActions {
/** @returns one microphone operation owned by the plugin lifecycle. */
createRecording: () => Recording
transcribe: (request: TranscriptionRequest, signal: AbortSignal) => Promise<RemoteResult<Transcript>>
prepare: (providerId: SpeechProviderId) => Promise<void>
prepare: (providerId: SpeechProviderId, options?: SpeechPreparationOptions) => Promise<void>
cancelPreparation: (providerId: SpeechProviderId) => Promise<void>
configure: (patch: SpeechSelectionPatch) => Promise<void>
}
@@ -57,6 +57,12 @@ export const zh = {
'downloadAdvice.storage': '请检查运行 DSH 的机器的剩余磁盘空间,以及模型目录的写入权限后重试。',
'downloadAdvice.unknown': '请检查运行 DSH 的机器的网络、磁盘空间和模型目录权限后重试。',
downloadSource: '下载来源:{source}',
sourceChoice: '模型下载源',
sourceAuto: '自动选择(推荐)',
sourceHuggingFace: 'Hugging Face',
sourceMirror: 'HF-Mirror(国内镜像)',
sourceAutoHelp: '优先使用响应较快的源,下载失败时自动尝试其他源。',
sourceManualHelp: '仅从所选源下载;已下载并通过校验的文件会复用。',
downloadCode: '错误码:{code}',
'reconnecting': '正在连接语音服务…',
'preparation.unprepared': '首次使用需要下载本地识别模型。',
@@ -77,7 +83,7 @@ export const zh = {
start: '开始录音', stop: '停止并识别', cancel: '取消', insert: '插入文字',
loading: '正在读取识别服务…', requesting: '请允许使用麦克风…', recording: '正在录音…',
interrupted: '录音中断,请重试。',
local: '音频在运行 DSH 的机器上识别。首次准备默认从 Hugging Face 下载模型,该机器需要能访问 Hugging Face 及其模型下载服务。如需代理,请在该机器上配置。',
local: '音频在运行 DSH 的机器上识别。需要下载模型时,请确保该机器能访问所选下载源及其文件服务。如需代理,请在该机器上配置。',
cloud: '音频将发送至所选云端服务。',
empty: '未识别到语音', cancelled: '已取消语音输入。',
conflict: '草稿已被修改。识别文字已保留,可在当前光标位置插入。',
@@ -144,6 +150,12 @@ export const en: Record<VoiceKey, string> = {
'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}',
sourceChoice: 'Model download source',
sourceAuto: 'Automatic (recommended)',
sourceHuggingFace: 'Hugging Face',
sourceMirror: 'HF-Mirror (China mirror)',
sourceAutoHelp: 'Prefer the first responding source and try another if a download fails.',
sourceManualHelp: 'Download only from the selected source. Verified downloaded files are reused.',
downloadCode: 'Error code: {code}',
'reconnecting': 'Connecting to speech recognition…',
'preparation.unprepared': 'First use downloads the local recognition models.',
@@ -164,7 +176,7 @@ export const en: Record<VoiceKey, string> = {
start: 'Start recording', stop: 'Stop and transcribe', cancel: 'Cancel', insert: 'Insert text',
loading: 'Loading recognition services…', requesting: 'Allow microphone access to continue…', recording: 'Recording…',
interrupted: 'Recording was interrupted. Please try again.',
local: 'Audio is recognized on the machine running DSH. First setup downloads models from Hugging Face by default, so that machine must be able to access Hugging Face and its model download services. If a proxy is needed, configure it on that machine.',
local: 'Audio is recognized on the machine running DSH. If models need downloading, that machine must be able to reach the selected source and its file services. Configure a proxy on that machine if needed.',
cloud: 'Audio will be sent to the selected cloud service.',
empty: 'No speech recognized', cancelled: 'Voice input cancelled.',
conflict: 'Your draft changed. The transcript is preserved and can be inserted at the current cursor.',
@@ -31,7 +31,9 @@ function registerUi(ctx: Context): void {
},
transcribe: async (request, signal) => await ctx.remote.speech.transcribe(request, signal),
configure: async (patch) => { const result = await ctx.remote.speech.configure(patch); if (!result.ok) throw result.error },
prepare: async (providerId) => { const result = await ctx.remote.speech.prepare(providerId); if (!result.ok) throw result.error },
prepare: async (providerId, options) => {
const result = await ctx.remote.speech.prepare(providerId, options); if (!result.ok) throw result.error
},
cancelPreparation: async (providerId) => {
const result = await ctx.remote.speech.cancelPreparation(providerId); if (!result.ok) throw result.error
},
@@ -77,6 +77,8 @@ it('withdraws its Remote, localized slot and microphone captures on disposal', a
const dispose = vi.spyOn(pending, 'dispose')
await actions.configure({ language: 'zh' })
await actions.prepare('local' as SpeechProviderId)
await actions.prepare('local' as SpeechProviderId, { downloadSource: 'https://hf-mirror.com' })
expect(b.prepare).toHaveBeenLastCalledWith('local', { downloadSource: 'https://hf-mirror.com' })
await actions.cancelPreparation('local' as SpeechProviderId)
for (const slot of ['plugins.bundle.config', 'plugins.bundle.activation'] as const) {
const item = b.ctx.slots.entries(slot)[0]!
@@ -70,6 +70,7 @@ it('starts preparation and offers retry without showing progress for completed o
expect(b.prepare).toHaveBeenCalledWith(id)
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider,
preparation: { phase: 'failed', message: 'network unavailable', steps: [{ kind: 'model', status: 'failed' }] } }} />)
await waitFor(() => { expect(screen.getByRole<HTMLButtonElement>('button', { name: zh.retryPrepare }).disabled).toBe(false) })
b.prepare.mockRejectedValueOnce(new Error('offline'))
fireEvent.click(screen.getByRole('button', { name: zh.retryPrepare }))
await screen.findByText('语音识别失败:offline')
@@ -155,3 +156,56 @@ it('shows provider-specific installation estimates before preparation and remove
b.rerender(<PreparationCard {...b.props} provider={{ ...provider, preparation: { phase: 'checking', startedAt: Date.now() } }} />)
expect(screen.queryByText(zh['setup.local'])).toBeNull()
})
it('offers Host sources, submits a manual choice, and retains it for retry', async () => {
const b = fixture({ phase: 'unprepared' })
const provider = { ...b.props.provider, downloadSources: ['https://huggingface.co', 'https://hf-mirror.com'] }
b.rerender(<PreparationCard {...b.props} provider={provider} />)
const picker = screen.getByLabelText<HTMLSelectElement>(zh.sourceChoice)
expect(picker.value).toBe('')
expect(within(picker).getAllByRole('option').map(option => option.textContent)).toEqual([zh.sourceAuto, zh.sourceHuggingFace, zh.sourceMirror])
expect(screen.getByText(zh.sourceAutoHelp)).toBeTruthy()
fireEvent.change(picker, { target: { value: 'https://hf-mirror.com' } })
expect(screen.getByText(zh.sourceManualHelp)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: zh.prepare }))
await waitFor(() => { expect(b.prepare).toHaveBeenCalledWith(id, { downloadSource: 'https://hf-mirror.com' }) })
b.rerender(<PreparationCard {...b.props} provider={{ ...provider, preparation: { phase: 'failed', message: 'offline' } }} />)
expect(screen.getByLabelText<HTMLSelectElement>(zh.sourceChoice).value).toBe('https://hf-mirror.com')
fireEvent.change(picker, { target: { value: 'https://huggingface.co' } })
fireEvent.click(screen.getByRole('button', { name: zh.retryPrepare }))
await waitFor(() => { expect(b.prepare).toHaveBeenLastCalledWith(id, { downloadSource: 'https://huggingface.co' }) })
fireEvent.change(picker, { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: zh.retryPrepare }))
await waitFor(() => { expect(b.prepare).toHaveBeenLastCalledWith(id) })
})
it('respects a fixed private source and removes a choice that the Host withdraws', async () => {
const b = fixture({ phase: 'unprepared' })
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider, downloadSources: ['https://huggingface.co', 'https://hf-mirror.com'] }} />)
fireEvent.change(screen.getByLabelText(zh.sourceChoice), { target: { value: 'https://hf-mirror.com' } })
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider, downloadSources: ['https://private.example'] }} />)
const picker = screen.getByLabelText<HTMLSelectElement>(zh.sourceChoice)
expect(picker.value).toBe('https://private.example')
expect(picker.disabled).toBe(true)
expect(within(picker).getAllByRole('option')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: zh.prepare }))
await waitFor(() => { expect(b.prepare).toHaveBeenCalledWith(id, { downloadSource: 'https://private.example' }) })
b.rerender(<PreparationCard {...b.props} provider={{ ...b.props.provider, downloadSources: [] }} />)
expect(screen.queryByLabelText(zh.sourceChoice)).toBeNull()
})
it('locks source changes while a preparation request is pending and hides them during active work', async () => {
const b = fixture({ phase: 'unprepared' }), pending = Promise.withResolvers<undefined>()
b.prepare.mockImplementation(async () => { await pending.promise })
const provider = { ...b.props.provider, downloadSources: ['https://huggingface.co', 'https://hf-mirror.com'] }
b.rerender(<PreparationCard {...b.props} provider={provider} />)
fireEvent.click(screen.getByRole('button', { name: zh.prepare }))
try {
expect(screen.getByLabelText<HTMLSelectElement>(zh.sourceChoice).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: zh.prepare }).disabled).toBe(true)
} finally { await act(async () => { pending.resolve(undefined); await pending.promise }) }
b.rerender(<PreparationCard {...b.props} provider={provider} connected={false} />)
expect(screen.getByLabelText<HTMLSelectElement>(zh.sourceChoice).disabled).toBe(true)
b.rerender(<PreparationCard {...b.props} provider={{ ...provider, preparation: { phase: 'downloading', resource: 'model.onnx', completedBytes: 0 } }} />)
expect(screen.queryByLabelText(zh.sourceChoice)).toBeNull()
})
@@ -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: 412b179d03e8b6a9eff143a5884bfb393996249b
README.zh.md: b9a00d7ba9cb454fab9b1153bb1855107b08a34a
README.md: 3d553d2a235346e8d51bf9c8c137bc824b0f3d48
README.zh.md: e4ab23cc48df0d93431eb22f6c17afcebfd78916
@@ -25,7 +25,11 @@ This provider recognizes speech with SenseVoiceSmall ONNX and Silero VAD on the
<a id="use-this-package"></a>
## Use this package
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.
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. Verified completed files remain reusable after cancellation or failure.
Before each missing asset is downloaded, the Host compares the Hugging Face-compatible `modelOrigins`, which default to `https://huggingface.co` and [HF-Mirror](https://hf-mirror.com). Concurrent HEAD requests follow the pinned file path and redirects through the Host's fetch proxy; the first 2xx response is tried first. `modelProbeTimeoutMs` defaults to 3000 ms; when every probe fails, downloads use the configured order. Network, HTTP, certificate and integrity failures try the remaining sources; cancellation, storage failures and unclassified failures stop preparation. Pinned revisions, sizes and SHA-256 hashes apply to every source. Response latency does not measure download throughput; the preparation deadline still bounds the complete download.
An explicit `modelOrigin` uses only that origin, without probing or public fallback. A single-entry `modelOrigins` also skips probing. Verified caches and explicit offline paths require no source requests. The voice UI offers the advertised sources before preparation or retry. A manual choice overrides selection for that task only, uses one source without fallback, and cannot replace an active task’s source. The Host rejects choices outside its configured origins; fully offline deployments advertise no download sources.
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.
@@ -25,7 +25,11 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用此包
[Bundle](../voice-input-bundle/README.zh.md) 在 DSH 主目录下提供绝对 `dataRoot`。准备会下载固定修订的模型文件,校验大小与 SHA-256,再加载模型。`precision` 默认为 `int8`,`fp32` 选择较大的参考权重。`modelDirectory` 提供包含所选 ONNX 文件和 `tokens.txt` 的已有绝对目录,`vadModelPath` 选择已有 Silero ONNX 文件。`modelOrigin` 选择兼容 Hugging Face 的下载源,固定路径和校验值保持不变。取消或失败后复用已完成并通过校验的文件。
[Bundle](../voice-input-bundle/README.zh.md) 在 DSH 主目录下提供绝对 `dataRoot`。准备会下载固定修订的模型文件,校验大小与 SHA-256,再加载模型。`precision` 默认为 `int8`,`fp32` 选择较大的参考权重。`modelDirectory` 提供包含所选 ONNX 文件和 `tokens.txt` 的已有绝对目录,`vadModelPath` 选择已有 Silero ONNX 文件。取消或失败后复用已完成并通过校验的文件。
下载每个缺失文件前,Host 会比较兼容 Hugging Face 的 `modelOrigins`,默认为 `https://huggingface.co` 和 [HF-Mirror](https://hf-mirror.com)。并发 HEAD 请求沿用固定文件路径,通过 Host 的 fetch 代理跟随重定向,先返回 2xx 的源优先下载。`modelProbeTimeoutMs` 默认为 3000 毫秒;所有探测均失败时按配置顺序下载。网络、HTTP、证书或完整性校验失败会尝试其他源;取消、存储错误和未分类错误会停止准备。所有源都必须符合固定修订、大小和 SHA-256 校验值。响应延迟不代表下载吞吐量,完整下载仍受准备阶段的总超时约束。
显式 `modelOrigin` 仅使用指定源,不探测也不回退到公共源。只有一个地址的 `modelOrigins` 同样跳过探测。已校验的缓存和显式离线路径无需请求下载源。语音 UI 在准备或重试前提供已公布的下载源。手动选择仅覆盖本次任务,只使用指定源而不回退,且不能替换进行中任务的源。Host 拒绝配置之外的源;完全离线部署不公布下载源。
下载失败会标明文件与下载源,并提供原因分类,以及可用的 HTTP 状态或错误码。公开状态不包含 URL 凭据、查询参数和原始底层错误消息。重试复用已校验文件,未完成的文件重新下载。
@@ -14,8 +14,12 @@ export interface Config {
vadModelPath?: string | undefined
/** Weight precision; INT8 minimizes first-use download and model storage. */
precision: 'int8' | 'fp32'
/** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */
modelOrigin: string
/** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */
modelOrigin?: string | undefined
/** Hugging Face-compatible origins compared before downloading each missing asset. */
modelOrigins: string[]
/** Deadline for concurrent HEAD probes, including redirects to the actual asset. */
modelProbeTimeoutMs: number
/** CPU intra-operation thread count. */
threads: number
/** Maximum speech segment length passed to the recognizer. */
@@ -53,7 +57,10 @@ export const Config: z<Partial<Config>, Config> = z.object({
modelDirectory: z.union([z.string().min(1), z.const(undefined)]),
vadModelPath: z.union([z.string().min(1), z.const(undefined)]),
precision: z.union(['int8', 'fp32']).default('int8'),
modelOrigin: z.string().pattern(/^https?:\/\/[^/\s?#@]+\/?$/).default('https://huggingface.co'),
modelOrigin: z.union([z.string().pattern(/^https?:\/\/[^/\s?#@]+\/?$/), z.const(undefined)]),
modelOrigins: z.array(z.string().pattern(/^https?:\/\/[^/\s?#@]+\/?$/)).min(1)
.default(['https://huggingface.co', 'https://hf-mirror.com']),
modelProbeTimeoutMs: z.natural().min(1).max(MAX_TIMER_DELAY_MS).default(3000),
threads: z.natural().min(1).default(2),
segmentSeconds: z.number().min(1).max(120).default(30),
vadThreshold: z.number().min(0).max(1).default(0.5),
@@ -21,12 +21,12 @@ export function apply(ctx: Context, config: Config): void {
for (const path of [config.dataRoot, config.modelDirectory, config.vadModelPath]) {
if (path !== undefined && !isAbsolute(path)) throw new Error(`SenseVoice paths must be absolute: ${path}`)
}
new URL(config.modelOrigin)
for (const origin of config.modelOrigin === undefined ? config.modelOrigins : [config.modelOrigin]) new URL(origin)
const worker = new SenseVoiceWorker(ctx, config)
const estimatedBytes = config.precision === 'int8' ? 1_000_000_000 : 2_000_000_000
ctx.effect(() => {
const unregister = ctx.speechToText.register({
info: { id: config.providerId as SpeechProviderId, name: `SenseVoiceSmall (${config.precision.toUpperCase()})`, location: 'host-local', languages,
info: { id: config.providerId as SpeechProviderId, name: `SenseVoiceSmall (${config.precision.toUpperCase()})`, location: 'host-local', languages, downloadSources: worker.downloadSources,
setupEstimate: { recommendedDiskBytes: estimatedBytes, expectedMemoryBytes: estimatedBytes,
minimumMinutes: 1, maximumMinutes: 10 } },
preparation: worker,
@@ -0,0 +1,41 @@
/** Host-side response comparison for pinned model files on Hugging Face-compatible origins. */
import { deadline } from '@deepseek-ai/dsh-timeout'
/**
* Prefer the first successful HEAD response while retaining other sources for download fallback.
* All probes settle before returning; if every probe fails, the configured order is preserved.
* @param assetUrl - revision-pinned upstream file URL.
* @param origins - nonempty configured origins, or one explicit deployment origin.
* @param timeoutMs - maximum probe duration, including redirects.
* @param signal - preparation cancellation or deadline.
* @returns deduplicated download URLs with the first responding source first; a single source needs no probe.
*/
export async function orderModelSources(assetUrl: string, origins: readonly string[], timeoutMs: number,
signal: AbortSignal): Promise<string[]> {
signal.throwIfAborted()
const path = new URL(assetUrl).pathname
const urls = [...new Set(origins.map(origin => new URL(path, origin).href))]
if (urls.length === 1) return urls
const finished = new AbortController()
using timeout = deadline(signal, timeoutMs, 'SPEECH_SOURCE_PROBE_TIMEOUT')
const probing = AbortSignal.any([timeout.signal, finished.signal])
const requests = urls.map(async url => ({ url, response: await fetch(url, { method: 'HEAD', signal: probing }) }))
let preferred: string | undefined
try {
preferred = await Promise.any(requests.map(async (request) => {
const { url, response } = await request
if (!response.ok) throw new Error(`Model source probe returned HTTP ${response.status}`)
return url
}))
} catch (_unavailableSources) {
// Downloads can still work when an origin refuses HEAD or exceeds the probe deadline.
} finally {
finished.abort()
const settled = await Promise.allSettled(requests)
await Promise.allSettled(settled.map(async (result) => {
if (result.status === 'fulfilled') await result.value.response.body?.cancel()
}))
}
signal.throwIfAborted()
return preferred === undefined ? urls : [preferred, ...urls.filter(url => url !== preferred)]
}
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import type { Context } from '@deepseek-ai/cordis'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import type { SpeechInput, SpeechPreparationState, SpeechPreparationStep, SpeechPreparationStepKind, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type { SpeechPreparationOptions, SpeechInput, SpeechPreparationState, SpeechPreparationStep, SpeechPreparationStepKind, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { z } from 'zod'
import type { Config } from './config.ts'
@@ -109,9 +109,13 @@ export class SenseVoiceWorker {
private state: SpeechPreparationState & { readonly steps: readonly SpeechPreparationStep[] }
private readonly listeners = new Set<() => void>()
private lastProgressAt = 0
private preparing: { abort: AbortController; settled: Promise<void>; completed: boolean } | undefined
/** Configured origins available for explicit downloads; offline deployments expose no choices. */
readonly downloadSources: readonly string[]
private preparing: { abort: AbortController; settled: Promise<void>; completed: boolean; downloadSource: string | undefined } | undefined
constructor(private readonly ctx: Context, private readonly config: Config) {
this.downloadSources = config.modelDirectory !== undefined && config.vadModelPath !== undefined ? []
: [...new Set((config.modelOrigin === undefined ? config.modelOrigins : [config.modelOrigin]).map(origin => new URL(origin).origin))]
const kinds: SpeechPreparationStepKind[] = ['check']
if (config.modelDirectory === undefined) kinds.push('model')
if (config.vadModelPath === undefined) kinds.push('vad')
@@ -172,16 +176,23 @@ export class SenseVoiceWorker {
})
}
/** Start one Host-owned preparation task; repeated callers join it. */
prepare(): void {
this.runPreparation(async (signal) => { await this.start(signal) })
/**
* Start or join one Host-owned preparation task with a fixed download source.
* @param options - omitted source uses deployment policy; a manual source must be advertised and disables fallback.
*/
prepare(options: SpeechPreparationOptions = {}): void {
const source = options.downloadSource
if (source !== undefined && !this.downloadSources.includes(source)) throw new Error('Speech download source is unavailable')
const config = source === undefined ? this.config : Object.assign({}, this.config, { modelOrigin: source })
this.runPreparation(async (signal) => { await this.start(signal, config) }, source)
}
private runPreparation(run: (signal: AbortSignal) => Promise<void>): void {
private runPreparation(run: (signal: AbortSignal) => Promise<void>, downloadSource?: string): void {
if (this.preparing && this.preparing.downloadSource !== downloadSource) throw new Error('Cancel preparation before changing its download source')
if (this.preparing || this.worker) return
this.lifetime.signal.throwIfAborted()
const abort = new AbortController()
const task = { abort, settled: Promise.resolve(), completed: false }
const task = { abort, settled: Promise.resolve(), completed: false, downloadSource }
this.preparing = task
task.settled = this.enqueue(run, abort.signal)
.catch((error: unknown) => {
@@ -236,13 +247,13 @@ export class SenseVoiceWorker {
return await job
}
private async start(signal: AbortSignal): Promise<Worker> {
private async start(signal: AbortSignal, preparationConfig: Config = this.config): Promise<Worker> {
if (this.worker?.closed) await this.stop()
if (this.worker) return this.worker
using setup = deadline(signal, this.config.prepareTimeoutMs, 'SPEECH_PREPARE_TIMEOUT')
const cached = this.runtime !== undefined
if (!cached) this.publish({ phase: 'checking', step: 'check', startedAt: Date.now() })
const runtime = this.runtime ?? await prepareRuntime(this.ctx, this.config, setup.signal, (state) => { this.publish(state) })
const runtime = this.runtime ?? await prepareRuntime(this.ctx, preparationConfig, setup.signal, (state) => { this.publish(state) })
this.runtime = runtime
setup.signal.throwIfAborted()
this.publish({ phase: cached ? 'waking' : 'loading', step: 'load', startedAt: Date.now() })
@@ -13,6 +13,7 @@ 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'
import { orderModelSources } from './model-sources.ts'
/** Release-pinned downloadable file. */
export interface Asset {
@@ -113,6 +114,7 @@ export async function downloadAsset(asset: Asset, root: string, signal: AbortSig
const response = await fetch(asset.url, { signal })
source = new URL(response.url || asset.url).origin
if (!response.ok || !response.body) {
await response.body?.cancel()
throw new SpeechDownloadError({ resource: asset.name, source, reason: 'http', status: response.status })
}
const digest = createHash('sha256')
@@ -148,7 +150,7 @@ export async function downloadAsset(asset: Asset, root: string, signal: AbortSig
/**
* Resolve the bundled native runtime and prepare verified ONNX models on demand.
* @param _ctx - Host context owning the preparation task.
* @param config - model paths, precision, and download origin.
* @param config - model paths, precision, and download source policy.
* @param signal - preparation cancellation or deadline.
* @param report - Host-owned progress publisher.
* @returns verified model and worker paths.
@@ -157,9 +159,18 @@ export async function prepareRuntime(_ctx: Context, config: Config, signal: Abor
report: (state: SpeechPreparationState) => void = () => {}): Promise<RuntimePaths> {
const { lock, modelRoot, paths } = resolveRuntime(config)
const download = async (asset: Asset, root: string, step: 'model' | 'vad'): Promise<void> => {
const url = new URL(asset.url)
const pinned = { ...asset, url: `${config.modelOrigin.replace(/\/$/, '')}${url.pathname}` }
await downloadAsset(pinned, root, signal, (state) => { report({ ...state, step }) })
if (await matchesAsset(join(root, asset.name), asset, signal)) return
const origins = config.modelOrigin === undefined ? config.modelOrigins : [config.modelOrigin]
const urls = await orderModelSources(asset.url, origins, config.modelProbeTimeoutMs, signal)
for (const [index, url] of urls.entries()) {
try {
await downloadAsset({ ...asset, url }, root, signal, (state) => { report({ ...state, step }) })
return
} catch (error) {
if (signal.aborted || !(error instanceof SpeechDownloadError) || error.download.reason === 'storage'
|| error.download.reason === 'unknown' || index === urls.length - 1) throw error
}
}
}
if (config.modelDirectory === undefined) {
report({ phase: 'checking', step: 'model', startedAt: Date.now() })
@@ -1,7 +1,7 @@
/** ONNX preparation reuses verified files and honors explicit offline deployments. */
import { writeFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { access, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { access, mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
@@ -19,7 +19,7 @@ const asset = (name: string): Asset => ({ name, url: `https://huggingface.co/own
const fake = vi.hoisted(() => ({ lock: {} }))
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return { ...actual, access: vi.fn(actual.access) }
return { ...actual, access: vi.fn(actual.access), rename: vi.fn(actual.rename) }
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
@@ -92,7 +92,7 @@ it('reports filesystem access errors instead of treating unreadable caches as ab
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'dsh-speech-onnx-')); roots.push(root)
fake.lock = { models: { int8: asset('model.int8.onnx'), fp32: asset('model.onnx') }, tokens: asset('tokens.txt'), vad: asset('silero_vad.onnx') }
const fetcher = vi.fn(async (_url: string) => new Response(bytes)); vi.stubGlobal('fetch', fetcher)
const fetcher = vi.fn(async (_url: string, _init?: RequestInit) => new Response(bytes)); vi.stubGlobal('fetch', fetcher)
return { root, fetcher }
}
it.each(['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64'])('prepares only model files on %s', async (target) => {
@@ -143,3 +143,61 @@ it('rejects cached files changed before the final verification stage', async ()
if (state.step === 'verify') writeFileSync(join(root, 'models', 'sensevoice-onnx', 'model.int8.onnx'), 'changed')
})).rejects.toThrow('verification failed')
})
it('downloads missing assets from the first responsive mirror and reuses verified files without probes', async () => {
const { root, fetcher } = await fixture()
fetcher.mockImplementation(async (url, init) => init?.method === 'HEAD'
? new Response(null, { status: url.startsWith('https://hf-mirror.com/') ? 200 : 503 }) : new Response(bytes))
const config = Config({ dataRoot: root })
await prepareRuntime(new Context(), config, new AbortController().signal)
const downloads = fetcher.mock.calls.filter(([, init]) => init?.method !== 'HEAD').map(([url]) => url)
expect(downloads).toEqual(['model.int8.onnx', 'tokens.txt', 'silero_vad.onnx']
.map(name => `https://hf-mirror.com/owner/model/resolve/revision/${name}`))
fetcher.mockClear()
await prepareRuntime(new Context(), config, new AbortController().signal)
expect(fetcher).not.toHaveBeenCalled()
})
it.each(['http', 'network', 'integrity'] as const)('falls back after a %s failure without accepting unverified bytes', async (reason) => {
const { root, fetcher } = await fixture()
fetcher.mockImplementation(async (url, init) => {
const mirror = url.startsWith('https://hf-mirror.com/')
if (init?.method === 'HEAD') return new Response(null, { status: mirror ? 200 : 503 })
if (!mirror) return new Response(bytes)
if (reason === 'network') throw new TypeError('fetch failed')
return reason === 'http' ? new Response('unavailable', { status: 503 }) : new Response('corrupt')
})
const paths = await prepareRuntime(new Context(), Config({ dataRoot: root }), new AbortController().signal)
expect(await readFile(paths.model)).toEqual(bytes)
const downloads = fetcher.mock.calls.filter(([, init]) => init?.method !== 'HEAD').map(([url]) => new URL(url).origin)
expect(downloads).toEqual(['https://hf-mirror.com', 'https://huggingface.co',
'https://hf-mirror.com', 'https://huggingface.co', 'https://hf-mirror.com', 'https://huggingface.co'])
})
it.each(['EACCES', 'unclassified'])('does not switch sources for a local publication failure (%s)', async (code) => {
const { root, fetcher } = await fixture()
vi.mocked(rename).mockRejectedValueOnce(Object.assign(new Error('cannot publish'), { code }))
await expect(prepareRuntime(new Context(), Config({ dataRoot: root }), new AbortController().signal)).rejects.toMatchObject({
download: { reason: code === 'EACCES' ? 'storage' : 'unknown' },
})
expect(fetcher.mock.calls.filter(([, init]) => init?.method !== 'HEAD')).toHaveLength(1)
})
it('does not switch sources after cancellation during a download', async () => {
const { root, fetcher } = await fixture(), abort = new AbortController(), reason = new Error('cancelled')
fetcher.mockImplementation(async (_url, init) => {
if (init?.method === 'HEAD') return new Response(null)
abort.abort(reason); throw reason
})
await expect(prepareRuntime(new Context(), Config({ dataRoot: root }), abort.signal)).rejects.toBe(reason)
expect(fetcher.mock.calls.filter(([, init]) => init?.method !== 'HEAD')).toHaveLength(1)
})
it('reports the final source when every download fails', async () => {
const { root, fetcher } = await fixture()
fetcher.mockResolvedValue(new Response(null, { status: 503 }))
await expect(prepareRuntime(new Context(), Config({ dataRoot: root }), new AbortController().signal)).rejects.toMatchObject({
download: { source: 'https://hf-mirror.com', reason: 'http', status: 503 },
})
expect(fetcher.mock.calls.filter(([, init]) => init?.method !== 'HEAD')).toHaveLength(2)
})
@@ -0,0 +1,92 @@
/** Source races are controlled by response barriers and share preparation cancellation. */
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
import { orderModelSources } from '../src/model-sources.ts'
const origins = ['https://huggingface.co', 'https://hf-mirror.com'] as const
const path = '/owner/model/resolve/pinned/model.onnx'
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
function race() {
const requests = new Map<string, ReturnType<typeof Promise.withResolvers<Response>> & { signal: AbortSignal }>()
const fetcher = vi.fn((url: string, init: RequestInit) => {
const signal = init.signal!
const pending = Promise.withResolvers<Response>()
const abort = () => { pending.reject(signal.reason) }
signal.addEventListener('abort', abort, { once: true })
requests.set(url, { ...pending, signal })
return pending.promise.finally(() => { signal.removeEventListener('abort', abort) })
})
vi.stubGlobal('fetch', fetcher)
const cancel = new AbortController()
const result = orderModelSources(origins[0] + path, origins, 50, cancel.signal)
onTestFinished(async () => { cancel.abort(); await result.catch(() => {}) })
const request = (origin: string) => requests.get(origin + path)!
return { request, fetcher, cancel, result }
}
it.each(origins)('prefers %s after its first successful response and joins the other request', async (winner) => {
vi.useFakeTimers()
const { request, fetcher, result } = race()
expect(fetcher).toHaveBeenCalledTimes(2)
expect(fetcher).toHaveBeenCalledWith(winner + path, expect.objectContaining({ method: 'HEAD' }))
request(winner).resolve(new Response(null))
expect(await result).toEqual([winner + path, ...origins.filter(origin => origin !== winner).map(origin => origin + path)])
expect(origins.every(origin => request(origin).signal.aborted)).toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('ignores a faster HTTP error and releases any returned bodies', async () => {
const { request, result } = race()
const cancel = vi.fn()
request(origins[0]).resolve(new Response(new ReadableStream({ cancel }), { status: 503 }))
request(origins[1]).resolve(new Response(null))
expect(await result).toEqual([origins[1] + path, origins[0] + path])
expect(cancel).toHaveBeenCalledOnce()
})
it('preserves configured order if every probe fails', async () => {
const { request, result } = race()
request(origins[0]).reject(new TypeError('offline'))
request(origins[1]).resolve(new Response(null, { status: 405 }))
expect(await result).toEqual(origins.map(origin => origin + path))
})
it('bounds probes without cancelling the following download', async () => {
vi.useFakeTimers()
const { request, result, cancel } = race()
await vi.advanceTimersByTimeAsync(50)
expect(await result).toEqual(origins.map(origin => origin + path))
expect(origins.every(origin => request(origin).signal.aborted)).toBe(true)
expect(cancel.signal.aborted).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('rejects preparation cancellation after joining both probes', async () => {
const { request, result, cancel } = race()
const reason = new Error('cancelled')
const rejected = expect(result).rejects.toBe(reason)
cancel.abort(reason)
await rejected
expect(origins.every(origin => request(origin).signal.aborted)).toBe(true)
})
it('uses a single deduplicated origin without probing and rejects already cancelled preparation', async () => {
const fetcher = vi.fn(); vi.stubGlobal('fetch', fetcher)
expect(await orderModelSources(origins[0] + path, ['https://private.example', 'https://private.example/'], 50,
new AbortController().signal)).toEqual(['https://private.example' + path])
await expect(orderModelSources(origins[0] + path, origins, 50, AbortSignal.abort(new Error('cancelled')))).rejects.toThrow('cancelled')
expect(fetcher).not.toHaveBeenCalled()
})
it('waits for body cleanup without changing the winning source', async () => {
const { request, result } = race()
const cleanup = Promise.withResolvers<undefined>(), cancelling = Promise.withResolvers<undefined>(), settled = vi.fn()
void result.then(settled)
request(origins[1]).resolve(new Response(new ReadableStream({ cancel() { cancelling.resolve(undefined); return cleanup.promise } })))
try {
await cancelling.promise
expect(settled).not.toHaveBeenCalled()
cleanup.resolve(undefined)
expect(await result).toEqual([origins[1] + path, origins[0] + path])
} finally { cleanup.resolve(undefined); await result }
})
@@ -14,7 +14,9 @@ it.each(['int8', 'fp32'] as const)('registers %s lazily and joins inference befo
await ctx.plugin(LocalSubprocess)
await ctx.plugin(SpeechToText, { defaultProvider: 'sensevoice-local', language: 'auto' })
const transcribe = vi.spyOn(SenseVoiceWorker.prototype, 'transcribe')
const fiber = ctx.plugin(Provider, { dataRoot: process.cwd(), precision })
const fiber = ctx.plugin(Provider, { dataRoot: process.cwd(), precision,
...precision === 'fp32' ? { modelOrigin: 'https://private.example' } : {},
})
await fiber
expect(transcribe).not.toHaveBeenCalled()
const speech = ctx.get('speechToText')!
@@ -38,3 +40,10 @@ it('refuses relative runtime, model and cache paths before registration', () =>
}
expect(() => Provider.Config({ dataRoot: process.cwd(), threads: 0 })).toThrow()
})
it.each([
{ modelOrigins: [] }, { modelOrigins: ['https://example.com/path'] }, { modelOrigins: ['https://user:[email protected]'] },
{ modelOrigin: 'file:///models' }, { modelProbeTimeoutMs: 0 }, { modelProbeTimeoutMs: 2_147_483_648 },
])('rejects invalid download source configuration %j', (config) => {
expect(() => Provider.Config({ dataRoot: process.cwd(), ...config })).toThrow()
})
@@ -63,6 +63,41 @@ it('keeps one worker warm across recordings and joins its exit on disposal', asy
await expect(worker.transcribe({ audio, language: 'en' }, signal())).rejects.toThrow('disposed')
})
it('pins a manual source to one preparation without changing deployment defaults', async () => {
const { worker } = await fixture()
expect(worker.downloadSources).toEqual(['https://huggingface.co', 'https://hf-mirror.com'])
worker.prepare({ downloadSource: 'https://hf-mirror.com' })
await vi.waitFor(() => { expect(worker.snapshot().phase).toBe('ready') })
expect(prepareRuntime).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ modelOrigin: 'https://hf-mirror.com' }),
expect.any(AbortSignal), expect.any(Function))
})
it('rejects unadvertised sources and preserves private and offline deployments', async () => {
const { worker } = await fixture({ modelOrigin: 'https://private.example/' })
expect(worker.downloadSources).toEqual(['https://private.example'])
expect(() => { worker.prepare({ downloadSource: 'https://hf-mirror.com' }) }).toThrow('unavailable')
expect(prepareRuntime).not.toHaveBeenCalled()
const offline = await fixture({ modelDirectory: process.cwd(), vadModelPath: process.cwd() })
expect(offline.worker.downloadSources).toEqual([])
expect(() => { offline.worker.prepare({ downloadSource: 'https://huggingface.co' }) }).toThrow('unavailable')
})
it('joins matching requests and refuses to change the source of active preparation', async () => {
const { worker } = await fixture(), entered = Promise.withResolvers<undefined>()
vi.mocked(prepareRuntime).mockImplementation(async (_ctx, _config, signal) => {
entered.resolve(undefined)
await new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('preparation cancelled')) }, { once: true }) })
throw new Error('unreachable')
})
worker.prepare({ downloadSource: 'https://hf-mirror.com' })
await entered.promise
worker.prepare({ downloadSource: 'https://hf-mirror.com' })
expect(() => { worker.prepare({ downloadSource: 'https://huggingface.co' }) }).toThrow('Cancel preparation')
expect(prepareRuntime).toHaveBeenCalledOnce()
await worker.cancel()
expect(worker.snapshot().phase).toBe('cancelled')
})
it('wakes verified caches on the first recording without preparing them again', async () => {
const { worker, spawn, root } = await fixture()
const paths = { tokens: root, model: root, vad: root, worker: fileURLToPath(new URL('./worker.fixture.mjs', import.meta.url)) }
@@ -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/README.md
README.md: 3dafb5c7cfb9c3946be329e6e67b876ddebfb322
README.zh.md: d665e5c05f7c220ecd838108b97ae886af7d952a
README.md: e96d3c09d8bea6a2ae7f19fd3ef195efbc8d6214
README.zh.md: 23a6a4850a540ecaafbd1a4ca6380caed32f8b91
@@ -27,6 +27,8 @@ This Service Definition selects named speech recognizers through `ctx.speechToTe
Load through the [voice input bundle](../voice-input-bundle/README.md), or compose the service with a provider and consumer. `defaultProvider` is required and selects an exact registered id; the bundle supplies `sensevoice-local`. `language` supplies the omitted language hint. A missing or duplicate provider fails explicitly.
Providers advertise preparation origins through `downloadSources`. `prepare(id, options)` forwards an optional `downloadSource` for one task; the provider validates the choice and refuses source changes during active preparation. Omission retains provider policy. Source choices are not persisted recognition preferences.
-----
<a id="understand-the-implementation"></a>
@@ -27,6 +27,8 @@ kind: "package-reference"
通过[语音输入 Bundle](../voice-input-bundle/README.zh.md)加载,或组合此服务、Provider 与消费者。`defaultProvider` 为必填项,选择确切的注册 id;Bundle 提供 `sensevoice-local`。`language` 提供省略的语言提示。缺失或重复的 Provider 会明确报错。
提供方通过 `downloadSources` 公布准备阶段可用的源。`prepare(id, options)` 为单次任务转发可选的 `downloadSource`;提供方校验选择,并拒绝在准备期间改源。省略时沿用提供方策略。下载源选择不属于持久化的识别偏好。
-----
<a id="understand-the-implementation"></a>
@@ -5,7 +5,7 @@ import z from '@deepseek-ai/schemastery'
// entry and `loader/volatile-update` merges.
import type {} from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type { SpeechProvider, SpeechProviderId, SpeechProviderInfo, SpeechSnapshot, SpeechSelectionPatch, SpeechRequest, SpeechSpec, Transcript } from './types.ts'
import type { SpeechPreparationOptions, SpeechProvider, SpeechProviderId, SpeechProviderInfo, SpeechSnapshot, SpeechSelectionPatch, SpeechRequest, SpeechSpec, Transcript } from './types.ts'
export type * from './types.ts'
@@ -153,11 +153,12 @@ export default class SpeechToText extends Service {
/**
* Start or join provider-owned preparation.
* @param id - exact registered provider identity.
* @param options - task-local source selection validated by the provider.
*/
prepare(id: SpeechProviderId): void {
prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void {
const registration = this.providers.get(id)
if (!registration) throw new Error(`Speech provider is unavailable: ${id}`)
registration.provider.preparation?.prepare()
registration.provider.preparation?.prepare(options)
}
/**
@@ -20,6 +20,8 @@ export interface SpeechProviderInfo {
/** Accepted language hints, including automatic detection when supported. */
readonly languages: readonly string[]
readonly setupEstimate?: SpeechSetupEstimate
/** Origins offered for an explicit preparation download; omitted or empty when no choice applies. */
readonly downloadSources?: readonly string[]
}
/** Ordered resource-preparation operations understood by the speech UI. Providers omit operations they do not need. */
@@ -75,14 +77,23 @@ export interface SpeechSnapshot {
readonly selection: SpeechSelection
}
/** Download choice for one preparation task; omission keeps the provider's configured selection policy. */
export interface SpeechPreparationOptions {
/** One advertised origin; providers reject unavailable sources and use a manual choice without fallback. */
readonly downloadSource?: string
}
/** Optional resource preparation controls. Providers without them are immediately usable. */
export interface SpeechPreparation {
/** @returns the latest Host-owned state. */
snapshot(): SpeechPreparationState
/** @param listener - state invalidation callback. @returns subscription disposer. */
subscribe(listener: () => void): () => void
/** Start or join the current preparation task; page and transport lifetimes do not own it. */
prepare(): void
/**
* Start or join the current preparation task; page and transport lifetimes do not own it.
* @param options - task-local download selection; providers reject changing the source of active work.
*/
prepare(options?: SpeechPreparationOptions): void
/** Cancel preparation. @returns after its resources settle. */
cancel(): Promise<void>
}
@@ -35,6 +35,8 @@ it('shares Host preparation across observers and does not cancel work when obser
expect((await stream.next()).value).toMatchObject({ providers: [{ id, preparation: { phase: 'unprepared' } }] })
service.prepare(id)
expect(prepare).toHaveBeenCalledOnce()
service.prepare(id, { downloadSource: 'https://mirror.example' })
expect(prepare).toHaveBeenLastCalledWith({ downloadSource: 'https://mirror.example' })
const next = stream.next()
state = { phase: 'downloading', resource: 'model', completedBytes: 638, totalBytes: 936 }; notify()
expect((await next).value).toMatchObject({ providers: [{ preparation: state }] })
@@ -1635,7 +1635,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult>',
description: 'Install a package using the same pnpm implementation as dsh plugin. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.',
description: 'Install a package using the same pnpm implementation as dsh plugin. GitHub repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.',
parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.' }],
returns: 'Package-manager diagnostics, the registries asked, and the observed activation outcome.',
},
@@ -1649,7 +1649,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>',
description: 'Stop an installation this manager owns and wait until its files are back.',
parameters: [{ name: 'requestId', description: 'The id the installation was started with.' }],
returns: '`cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.',
returns: '`cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being applied, `not-running` for any other id.',
},
{
signature: '@Remote removeBundle(name: string): Promise<ChangeResult>',
@@ -2555,9 +2555,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'after preferences are saved.',
},
{
signature: '@Remote prepare(providerId: SpeechProviderId): void',
signature: '@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void',
description: 'Start or join one Host-owned preparation task.',
parameters: [{ name: 'providerId', description: 'selected recognizer.' }],
parameters: [{ name: 'providerId', description: 'selected recognizer.' }, { name: 'options', description: 'task-local source selection validated by the provider.' }],
},
{
signature: '@Remote cancelPreparation(providerId: SpeechProviderId): Promise<void>',
@@ -2609,9 +2609,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'after the profile write and the live update it applies.',
},
{
signature: 'prepare(id: SpeechProviderId): void',
signature: 'prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void',
description: 'Start or join provider-owned preparation.',
parameters: [{ name: 'id', description: 'exact registered provider identity.' }],
parameters: [{ name: 'id', description: 'exact registered provider identity.' }, { name: 'options', description: 'task-local source selection validated by the provider.' }],
},
{
signature: 'async cancelPreparation(id: SpeechProviderId): Promise<void>',
@@ -6745,7 +6745,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SpeechPreparation',
declaration: 'export interface SpeechPreparation {\n snapshot(): SpeechPreparationState;\n subscribe(listener: () => void): () => void;\n prepare(): void;\n cancel(): Promise<void>;\n}',
declaration: 'export interface SpeechPreparation {\n snapshot(): SpeechPreparationState;\n subscribe(listener: () => void): () => void;\n prepare(options?: SpeechPreparationOptions): void;\n cancel(): Promise<void>;\n}',
},
{
name: 'SpeechPreparationOptions',
declaration: 'export interface SpeechPreparationOptions {\n readonly downloadSource?: string;\n}',
},
{
name: 'SpeechPreparationState',
@@ -6769,7 +6773,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SpeechProviderInfo',
declaration: 'export interface SpeechProviderInfo {\n readonly id: SpeechProviderId;\n readonly name: string;\n readonly location: \'host-local\' | \'cloud\';\n readonly languages: readonly string[];\n readonly setupEstimate?: SpeechSetupEstimate;\n}',
declaration: 'export interface SpeechProviderInfo {\n readonly id: SpeechProviderId;\n readonly name: string;\n readonly location: \'host-local\' | \'cloud\';\n readonly languages: readonly string[];\n readonly setupEstimate?: SpeechSetupEstimate;\n readonly downloadSources?: readonly string[];\n}',
},
{
name: 'SpeechProviderView',
+1
View File
@@ -623,6 +623,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
JobSettleCause: 'jobs.md',
SpeechDownloadFailure: 'voice-input.md',
SpeechPreparationState: 'voice-input.md',
SpeechPreparationOptions: 'voice-input.md',
SpeechPreparationStep: 'voice-input.md',
SpeechPreparationStepKind: 'voice-input.md',
SpeechProviderView: 'voice-input.md',