chore: prepare GoodBuddy 0.8.2
Cross-platform packages / Validate source (push) Waiting to run
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, macos, macos-15-intel) (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
Cross-platform packages / Publish GitHub Release (push) Blocked by required conditions
Cross-platform packages / ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions

This commit is contained in:
lofyer
2026-08-06 22:47:13 +08:00
parent 8d00e6371d
commit b8fc7bc86e
114 changed files with 22916 additions and 1560 deletions
+809
View File
@@ -0,0 +1,809 @@
import { createHash, randomUUID } from 'node:crypto'
import {
copyFile,
lstat,
mkdir,
open,
readFile,
readdir,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { z } from 'zod'
import {
installedSpeechModelSchema,
speechModelCatalogEntrySchema,
speechModelIdSchema,
speechModelSnapshotSchema,
type InstalledSpeechModel,
type SpeechModelCatalogEntry,
type SpeechModelFileSpec,
type SpeechModelOperation,
type SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MAX_REDIRECTS = 3
const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json'
const PARTIAL_SUFFIX = '.partial'
const SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS = new Set([
'huggingface.co',
'cdn-lfs.huggingface.co',
'cdn-lfs-us-1.huggingface.co',
'cdn-lfs-eu-1.huggingface.co',
'cdn-lfs.hf.co',
'cdn-lfs-us-1.hf.co',
'cdn-lfs-eu-1.hf.co',
'cas-bridge.xethub.hf.co'
])
const selectionSchema = z
.object({
selectedModelId: speechModelIdSchema.nullable()
})
.strict()
const executableExtensionPattern =
/\.(?:app|bat|bin|cmd|com|cpl|dll|dmg|exe|gadget|hta|inf|ins|ipa|iso|jar|js|jse|lnk|msi|msp|mst|pif|ps1|reg|scr|sh|sys|vb|vbe|vbs|ws|wsc|wsf|wsh)$/iu
type ActiveOperation = {
controller: AbortController
progress: SpeechModelOperation
}
export type SpeechModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
catalog?: readonly SpeechModelCatalogEntry[]
maxFileBytes?: number
}
export type SelectedSpeechRuntimeModel = {
id: string
family: SpeechModelCatalogEntry['family']
directory: string
files: InstalledSpeechModel['files']
}
function cloneCatalogEntry(
entry: SpeechModelCatalogEntry
): SpeechModelCatalogEntry {
return speechModelCatalogEntrySchema.parse(entry)
}
function abortError(): DOMException {
return new DOMException('The operation was aborted', 'AbortError')
}
function ensureNotAborted(signal: AbortSignal): void {
if (signal.aborted) {
throw abortError()
}
}
function validateMaximumBytes(value: number | undefined): number {
const maximum = value ?? DEFAULT_MAX_FILE_BYTES
if (
!Number.isSafeInteger(maximum) ||
maximum <= 0 ||
maximum > 8 * 1024 * 1024 * 1024
) {
throw new RangeError('maxFileBytes must be a positive safe integer')
}
return maximum
}
function safeChild(parent: string, name: string): string {
const child = resolve(parent, name)
if (dirname(child) !== resolve(parent)) {
throw new Error('模型路径超出受管目录')
}
return child
}
function validateDownloadUrl(value: string): URL {
const url = new URL(value)
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.hash ||
!SPEECH_MODEL_ALLOWED_DOWNLOAD_HOSTS.has(url.hostname.toLowerCase())
) {
throw new Error('模型下载地址必须是允许的 Hugging Face HTTPS 地址')
}
return url
}
async function hashFile(
path: string,
signal?: AbortSignal
): Promise<{
size: number
sha256: string
}> {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
const buffer = Buffer.allocUnsafe(64 * 1024)
try {
while (true) {
if (signal) {
ensureNotAborted(signal)
}
const { bytesRead } = await handle.read(buffer, 0, buffer.length)
if (bytesRead === 0) {
break
}
hash.update(buffer.subarray(0, bytesRead))
size += bytesRead
}
} finally {
await handle.close()
}
return { size, sha256: hash.digest('hex') }
}
export class SpeechModelManager {
readonly rootDirectory: string
private readonly transport: typeof fetch
private readonly catalog: SpeechModelCatalogEntry[]
private readonly maxFileBytes: number
private readonly operations = new Map<string, ActiveOperation>()
constructor(options: SpeechModelManagerOptions) {
if (!options.userDataDirectory.trim()) {
throw new Error('userDataDirectory is required')
}
this.rootDirectory = resolve(
options.userDataDirectory,
'models',
'speech'
)
this.transport = options.fetch
this.catalog = (options.catalog ?? SPEECH_MODEL_CATALOG).map(
cloneCatalogEntry
)
if (new Set(this.catalog.map((entry) => entry.id)).size !== this.catalog.length) {
throw new Error('语音模型目录包含重复 ID')
}
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
}
async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot()
const installed = await this.readInstalled()
const selected = await this.readSelection()
const installedIds = new Set(installed.map((model) => model.id))
return speechModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory,
catalog: this.catalog.map(cloneCatalogEntry),
installed,
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
})),
selectedModelId:
selected && installedIds.has(selected) ? selected : null
})
}
async getSnapshot(): Promise<SpeechModelSnapshot> {
return this.snapshot()
}
async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined
> {
const snapshot = await this.snapshot()
if (!snapshot.selectedModelId) {
return undefined
}
const catalogEntry = this.catalog.find(
(entry) => entry.id === snapshot.selectedModelId
)
const installed = snapshot.installed.find(
(entry) => entry.id === snapshot.selectedModelId
)
if (!catalogEntry || !installed) {
return undefined
}
return {
id: installed.id,
family: catalogEntry.family,
directory: this.modelDirectory(installed.id),
files: installed.files.map((file) => ({ ...file }))
}
}
async install(
modelId: string,
externalSignal?: AbortSignal
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
if (entry.manualOnly) {
throw new Error(
entry.manualReason ?? '该模型只能从本地目录导入'
)
}
const downloadableFiles = entry.files.filter(
(
file
): file is SpeechModelFileSpec & {
download: NonNullable<SpeechModelFileSpec['download']>
} => file.download !== undefined
)
if (downloadableFiles.length !== entry.files.length) {
throw new Error('模型下载元数据不完整')
}
const totalBytes = downloadableFiles.reduce(
(total, file) => total + file.download.size,
0
)
if (!Number.isSafeInteger(totalBytes)) {
throw new RangeError('模型总大小超出安全范围')
}
const operation = this.beginOperation(
entry.id,
'download',
totalBytes
)
const detachExternalAbort = this.attachExternalSignal(
externalSignal,
operation.controller
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
for (const file of downloadableFiles) {
ensureNotAborted(operation.controller.signal)
operation.progress.phase = 'transferring'
operation.progress.currentFile = file.name
const destination = safeChild(stagingDirectory, file.name)
await this.downloadFile(
file,
destination,
operation,
operation.controller.signal
)
}
operation.progress.phase = 'installing'
operation.progress.currentFile = null
const installed = await this.createInstalledManifest(
entry,
'download',
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
return installed
} finally {
detachExternalAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
cancel(modelId: string): boolean {
speechModelIdSchema.parse(modelId)
const operation = this.operations.get(modelId)
if (!operation) {
return false
}
operation.controller.abort()
return true
}
async remove(modelId: string): Promise<void> {
speechModelIdSchema.parse(modelId)
this.cancel(modelId)
await this.ensureRoot()
const target = this.modelDirectory(modelId)
await rm(target, { recursive: true, force: true })
const selected = await this.readSelection()
if (selected === modelId) {
await this.writeSelection(null)
}
}
async select(modelId: string | null): Promise<void> {
if (modelId !== null) {
speechModelIdSchema.parse(modelId)
const installed = await this.readInstalled()
if (!installed.some((model) => model.id === modelId)) {
throw new Error('只能选择已安装的语音模型')
}
}
await this.writeSelection(modelId)
}
async registerLocalDirectory(
modelId: string,
sourceDirectory: string,
externalSignal?: AbortSignal
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
const source = resolve(sourceDirectory)
const operation = this.beginOperation(entry.id, 'import', null)
const detachExternalAbort = this.attachExternalSignal(
externalSignal,
operation.controller
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
await this.validateLocalDirectory(
source,
entry,
operation.controller.signal
)
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
for (const file of entry.files) {
ensureNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
await copyFile(sourceFile, destination)
ensureNotAborted(operation.controller.signal)
const copied = await stat(destination)
if (copied.size > this.maxFileBytes) {
throw new RangeError(`模型文件过大:${file.name}`)
}
operation.progress.completedBytes += copied.size
}
operation.progress.totalBytes =
operation.progress.completedBytes
operation.progress.phase = 'installing'
operation.progress.currentFile = null
const installed = await this.createInstalledManifest(
entry,
'local',
stagingDirectory,
operation.controller.signal
)
ensureNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
return installed
} finally {
detachExternalAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
private async ensureRoot(): Promise<void> {
await mkdir(this.rootDirectory, { recursive: true })
}
private modelDirectory(modelId: string): string {
const parsedId = speechModelIdSchema.parse(modelId)
return safeChild(this.rootDirectory, parsedId)
}
private requireCatalogEntry(modelId: string): SpeechModelCatalogEntry {
const parsedId = speechModelIdSchema.parse(modelId)
const entry = this.catalog.find((candidate) => candidate.id === parsedId)
if (!entry) {
throw new Error('未知的语音模型')
}
return entry
}
private beginOperation(
modelId: string,
kind: SpeechModelOperation['kind'],
totalBytes: number | null
): ActiveOperation {
if (this.operations.has(modelId)) {
throw new Error('该模型已有进行中的操作')
}
const operation: ActiveOperation = {
controller: new AbortController(),
progress: {
modelId,
kind,
phase: 'preparing',
currentFile: null,
completedBytes: 0,
totalBytes
}
}
this.operations.set(modelId, operation)
return operation
}
private attachExternalSignal(
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
if (!signal) {
return () => undefined
}
const abort = (): void => controller.abort()
if (signal.aborted) {
controller.abort()
} else {
signal.addEventListener('abort', abort, { once: true })
}
return () => signal.removeEventListener('abort', abort)
}
private async assertNotInstalled(modelId: string): Promise<void> {
try {
await lstat(this.modelDirectory(modelId))
throw new Error('语音模型已安装')
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return
}
throw error
}
}
private async createStagingDirectory(modelId: string): Promise<string> {
const directory = safeChild(
this.rootDirectory,
`.install-${modelId}-${randomUUID()}`
)
await mkdir(directory, { recursive: false })
return directory
}
private async fetchFollowingRedirects(
initialUrl: string,
signal: AbortSignal
): Promise<Response> {
let url = validateDownloadUrl(initialUrl)
for (let redirectCount = 0; ; redirectCount += 1) {
ensureNotAborted(signal)
const response = await this.transport(url, {
method: 'GET',
redirect: 'manual',
credentials: 'omit',
cache: 'no-store',
signal
})
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirectCount >= MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new Error('模型下载重定向次数过多')
}
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location) {
throw new Error('模型下载重定向缺少地址')
}
url = validateDownloadUrl(new URL(location, url).toString())
continue
}
return response
}
}
private async downloadFile(
file: SpeechModelFileSpec & {
download: NonNullable<SpeechModelFileSpec['download']>
},
destination: string,
operation: ActiveOperation,
signal: AbortSignal
): Promise<void> {
if (
file.download.size > this.maxFileBytes ||
file.download.size <= 0
) {
throw new RangeError(`模型文件大小超出限制:${file.name}`)
}
const response = await this.fetchFollowingRedirects(
file.download.url,
signal
)
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new Error(`模型下载失败:HTTP ${response.status}`)
}
if (!response.body) {
throw new Error('模型下载响应没有内容')
}
const declaredLength = response.headers.get('content-length')
if (declaredLength !== null) {
const parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength !== file.download.size
) {
await response.body.cancel().catch(() => undefined)
throw new Error(`模型文件大小不匹配:${file.name}`)
}
}
const partialPath = `${destination}${PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
written += result.value.byteLength
if (
written > file.download.size ||
written > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`模型文件过大:${file.name}`)
}
await handle.write(result.value)
hash.update(result.value)
operation.progress.completedBytes += result.value.byteLength
}
} catch (error) {
await reader.cancel().catch(() => undefined)
throw error
} finally {
await handle.close()
}
if (written !== file.download.size) {
throw new Error(`模型文件大小不匹配:${file.name}`)
}
if (hash.digest('hex') !== file.download.sha256) {
throw new Error(`模型文件校验失败:${file.name}`)
}
await rename(partialPath, destination)
}
private async validateLocalDirectory(
sourceDirectory: string,
entry: SpeechModelCatalogEntry,
signal: AbortSignal
): Promise<void> {
const sourceInfo = await lstat(sourceDirectory)
if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) {
throw new Error('本地模型来源必须是普通目录')
}
await this.rejectUnsafeLocalEntries(sourceDirectory, signal, {
visited: 0
})
for (const expectedFile of entry.files) {
ensureNotAborted(signal)
const sourceFile = safeChild(sourceDirectory, expectedFile.name)
const sourceFileInfo = await lstat(sourceFile)
if (
!sourceFileInfo.isFile() ||
sourceFileInfo.isSymbolicLink()
) {
throw new Error(`模型文件必须是普通文件:${expectedFile.name}`)
}
if (
sourceFileInfo.size <= 0 ||
sourceFileInfo.size > this.maxFileBytes
) {
throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
}
if (
expectedFile.download &&
(sourceFileInfo.size !== expectedFile.download.size ||
(await hashFile(sourceFile, signal)).sha256 !==
expectedFile.download.sha256)
) {
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
}
}
}
private async rejectUnsafeLocalEntries(
directory: string,
signal: AbortSignal,
counter: { visited: number }
): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
ensureNotAborted(signal)
counter.visited += 1
if (counter.visited > 4_096) {
throw new Error('本地模型目录包含过多条目')
}
if (executableExtensionPattern.test(entry.name)) {
throw new Error(`本地模型目录包含可执行文件:${entry.name}`)
}
const path = safeChild(directory, entry.name)
const metadata = await lstat(path)
if (metadata.isSymbolicLink()) {
throw new Error(`本地模型目录不能包含符号链接:${entry.name}`)
}
if (metadata.isDirectory()) {
await this.rejectUnsafeLocalEntries(path, signal, counter)
} else if (
metadata.isFile() &&
(((metadata.mode & 0o111) !== 0 &&
process.platform !== 'win32') ||
(await this.hasExecutableSignature(path)))
) {
throw new Error(`本地模型目录包含可执行文件:${entry.name}`)
}
}
}
private async hasExecutableSignature(path: string): Promise<boolean> {
const handle = await open(path, 'r')
const header = Buffer.alloc(4)
try {
const { bytesRead } = await handle.read(header, 0, header.length, 0)
if (bytesRead < 2) {
return false
}
if (
(header[0] === 0x4d && header[1] === 0x5a) ||
(header[0] === 0x23 && header[1] === 0x21)
) {
return true
}
if (
bytesRead === 4 &&
((header[0] === 0x7f &&
header[1] === 0x45 &&
header[2] === 0x4c &&
header[3] === 0x46) ||
[
'cafebabe',
'cefaedfe',
'cffaedfe',
'feedface',
'feedfacf'
].includes(header.toString('hex')))
) {
return true
}
return false
} finally {
await handle.close()
}
}
private async createInstalledManifest(
entry: SpeechModelCatalogEntry,
source: InstalledSpeechModel['source'],
stagingDirectory: string,
signal: AbortSignal
): Promise<InstalledSpeechModel> {
const files = []
for (const file of entry.files) {
ensureNotAborted(signal)
const metadata = await hashFile(
safeChild(stagingDirectory, file.name),
signal
)
files.push({
name: file.name,
role: file.role,
...metadata
})
}
const manifest = installedSpeechModelSchema.parse({
id: entry.id,
displayName: entry.displayName,
source,
installedAt: new Date().toISOString(),
files
})
await writeFile(
safeChild(stagingDirectory, MANIFEST_FILE_NAME),
`${JSON.stringify(manifest, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
return manifest
}
private async readInstalled(): Promise<InstalledSpeechModel[]> {
const entries = await readdir(this.rootDirectory, {
withFileTypes: true
})
const installed: InstalledSpeechModel[] = []
for (const entry of entries) {
if (
!entry.isDirectory() ||
entry.name.startsWith('.install-') ||
!speechModelIdSchema.safeParse(entry.name).success
) {
continue
}
try {
const manifestPath = safeChild(
this.modelDirectory(entry.name),
MANIFEST_FILE_NAME
)
const manifest = installedSpeechModelSchema.parse(
JSON.parse(await readFile(manifestPath, 'utf8')) as unknown
)
if (manifest.id === entry.name) {
installed.push(manifest)
}
} catch {
// Incomplete or externally modified directories are not installed.
}
}
return installed.sort((left, right) => left.id.localeCompare(right.id))
}
private async readSelection(): Promise<string | null> {
try {
const value = selectionSchema.parse(
JSON.parse(
await readFile(
safeChild(this.rootDirectory, SELECTION_FILE_NAME),
'utf8'
)
) as unknown
)
return value.selectedModelId
} catch (error) {
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT'
) {
return null
}
return null
}
}
private async writeSelection(modelId: string | null): Promise<void> {
await this.ensureRoot()
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
const partial = safeChild(
this.rootDirectory,
`${SELECTION_FILE_NAME}.${randomUUID()}${PARTIAL_SUFFIX}`
)
await writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
try {
await rename(partial, target)
} catch (error) {
await rm(partial, { force: true })
throw error
}
}
}
export function createSpeechModelManager(
options: SpeechModelManagerOptions
): SpeechModelManager {
return new SpeechModelManager(options)
}