Files
goodbuddy/src/main/speech/speech-model-manager.ts
T
mesalogo a0977645a3 fix: enable verified ModelScope speech downloads
Selecting ModelScope could leave supported speech models unavailable because their fixed targets were incomplete and the official LFS redirect host was not allowed. Speech models now resolve complete per-source artifact fingerprints while preserving integrity checks, ZIP compatibility, and source isolation.

Release note: 修复选择 ModelScope 后部分语音模型仍无法下载的问题;六个语音模型现均可从已验证的固定 ModelScope 来源下载。
2026-08-20 23:57:38 +08:00

1137 lines
33 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto'
import {
copyFile,
lstat,
mkdir,
open,
readFile,
readdir,
rename,
rm,
stat,
writeFile
} from 'node:fs/promises'
import { resolve } from 'node:path'
import { z } from 'zod'
import {
installedSpeechModelSchema,
speechModelCatalogEntrySchema,
speechModelCatalogViewEntrySchema,
speechModelIdSchema,
speechModelSnapshotSchema,
type InstalledSpeechModel,
type SpeechModelCatalogEntry,
type SpeechModelCatalogViewEntry,
type SpeechModelFileSpec,
type SpeechModelOperation,
type SpeechModelSnapshot
} from '../../shared/speech-model-contracts'
import {
MODEL_DOWNLOAD_SOURCES,
getMaximumModelPackageBytes,
getModelDownloadAvailability,
getModelPackageFingerprints,
modelPackageFingerprintMatches,
resolveModelDownloadPackage,
type ModelDownloadSource,
type ResolvedModelArtifactFile
} from '../../shared/model-download-contracts'
import { SPEECH_MODEL_CATALOG } from './speech-model-catalog'
import {
exportModelArchive,
extractModelArchive
} from '../model-archive'
import { fetchModelDownloadResponse } from '../model-download-transport'
import {
MODEL_PARTIAL_SUFFIX,
attachModelAbortSignal,
cleanupStaleModelInstallArtifacts,
createModelStagingDirectory,
ensureModelOperationNotAborted,
fingerprintModelFile,
hashModelFile,
managedModelChild,
modelFileFingerprintMatches,
type ModelFileFingerprint,
writeModelBuffer
} from '../model-package-utils'
import { isMissingFileError } from '../settings-file-utils'
const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
const MANIFEST_FILE_NAME = 'manifest.json'
const SELECTION_FILE_NAME = '.selection.json'
const MAXIMUM_ARCHIVE_BYTES = 4 * 1024 * 1024 * 1024 - 1
const ARCHIVE_OVERHEAD_BYTES = 1024 * 1024
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
}
type SpeechSelectionFileOperations = {
writeFile: typeof writeFile
rename: typeof rename
}
type CachedSelectedSpeechRuntimeModel = {
model: SelectedSpeechRuntimeModel
manifestFingerprint: ModelFileFingerprint
fileFingerprints: Map<string, ModelFileFingerprint>
}
export type SpeechModelManagerOptions = {
userDataDirectory: string
fetch: typeof fetch
catalog?: readonly SpeechModelCatalogEntry[]
getDownloadSource?: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
maxFileBytes?: number
selectionFileOperations?: Partial<SpeechSelectionFileOperations>
}
export type SelectedSpeechRuntimeModel = {
id: string
family: SpeechModelCatalogEntry['family']
directory: string
files: InstalledSpeechModel['files']
}
function cloneCatalogEntry(
entry: SpeechModelCatalogEntry
): SpeechModelCatalogEntry {
return speechModelCatalogEntrySchema.parse(entry)
}
function toCatalogView(entry: SpeechModelCatalogEntry) {
const { repositoryUrls, files, ...metadata } = entry
void repositoryUrls
return speechModelCatalogViewEntrySchema.parse({
...metadata,
files: files.map((file) => ({
name: file.name,
role: file.role,
size: file.size,
sha256: file.sha256
})),
downloadAvailability: MODEL_DOWNLOAD_SOURCES.map((source) =>
getModelDownloadAvailability(files, source)
)
})
}
function speechModelPackageMatches(
entry: SpeechModelCatalogEntry,
files: readonly {
name: string
role: string
size: number
sha256: string
}[]
): boolean {
return getModelPackageFingerprints(entry.files).some(
(fingerprint) =>
modelPackageFingerprintMatches(fingerprint, files)
)
}
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 {
return managedModelChild(parent, name, '模型路径超出受管目录')
}
export class SpeechModelManager {
readonly rootDirectory: string
private readonly transport: typeof fetch
private readonly catalog: SpeechModelCatalogEntry[]
private readonly catalogViews: SpeechModelCatalogViewEntry[]
private readonly getDownloadSource: () =>
| ModelDownloadSource
| Promise<ModelDownloadSource>
private readonly maxFileBytes: number
private readonly selectionFileOperations: SpeechSelectionFileOperations
private readonly operations = new Map<string, ActiveOperation>()
private readonly activeSelectionPartialNames = new Set<string>()
private selectedRuntimeModel?: Promise<
CachedSelectedSpeechRuntimeModel | undefined
>
private selectedRuntimeGeneration = 0
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.getDownloadSource =
options.getDownloadSource ?? (() => 'modelscope')
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.catalogViews = this.catalog.map(toCatalogView)
this.maxFileBytes = validateMaximumBytes(options.maxFileBytes)
this.selectionFileOperations = {
writeFile,
rename,
...options.selectionFileOperations
}
}
async snapshot(): Promise<SpeechModelSnapshot> {
await this.ensureRoot()
await this.cleanupStaleArtifacts()
const [installed, selected, selectedDownloadSource] =
await Promise.all([
this.readInstalled(),
this.readSelection(),
this.getDownloadSource()
])
const installedIds = new Set(installed.map((model) => model.id))
return speechModelSnapshotSchema.parse({
rootDirectory: this.rootDirectory,
selectedDownloadSource,
catalog: this.catalogViews,
installed,
operations: [...this.operations.values()].map((operation) => ({
...operation.progress
})),
selectedModelId:
selected && installedIds.has(selected) ? selected : null
})
}
async getSnapshot(): Promise<SpeechModelSnapshot> {
return this.snapshot()
}
getRepositoryUrl(
modelId: string,
source: ModelDownloadSource
): string {
const entry = this.requireCatalogEntry(modelId)
resolveModelDownloadPackage(entry.files, source)
const repositoryUrl = entry.repositoryUrls[source]
if (!repositoryUrl) {
throw new Error('当前下载源暂不提供此模型的仓库')
}
return repositoryUrl
}
async getSelectedRuntimeModel(): Promise<
SelectedSpeechRuntimeModel | undefined
> {
const selectedModelId = await this.readSelection()
if (!selectedModelId) {
this.invalidateSelectedRuntimeModel()
return undefined
}
const cachedPromise = this.selectedRuntimeModel
if (cachedPromise) {
const cached = await cachedPromise
if (
cached?.model.id === selectedModelId &&
(await this.selectedRuntimeFingerprintsMatch(cached))
) {
return this.cloneSelectedRuntimeModel(cached.model)
}
if (this.selectedRuntimeModel === cachedPromise) {
this.invalidateSelectedRuntimeModel()
}
}
const selected = await this.getOrCreateSelectedRuntimeModel(
selectedModelId
)
return selected
? this.cloneSelectedRuntimeModel(selected.model)
: undefined
}
private getOrCreateSelectedRuntimeModel(
selectedModelId: string
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
const current = this.selectedRuntimeModel
if (current) {
return current
}
const generation = this.selectedRuntimeGeneration
const resolution = this.resolveSelectedRuntimeModel(
selectedModelId,
generation
)
const tracked = resolution.catch((error) => {
if (this.selectedRuntimeModel === tracked) {
this.selectedRuntimeModel = undefined
}
throw error
})
this.selectedRuntimeModel = tracked
return tracked
}
async install(
modelId: string,
downloadSource?: ModelDownloadSource,
externalSignal?: AbortSignal
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
if (entry.manualOnly) {
throw new Error(
entry.manualReason ?? '该模型只能从本地目录导入'
)
}
const selectedDownloadSource =
downloadSource ?? (await this.getDownloadSource())
const resolvedPackage = resolveModelDownloadPackage(
entry.files,
selectedDownloadSource
)
const operation = this.beginOperation(
entry.id,
'download',
resolvedPackage.totalBytes,
resolvedPackage.source
)
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 resolvedPackage.files) {
ensureModelOperationNotAborted(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
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
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)
this.invalidateSelectedRuntimeModel()
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)
this.invalidateSelectedRuntimeModel()
}
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) {
ensureModelOperationNotAborted(operation.controller.signal)
operation.progress.currentFile = file.name
const sourceFile = safeChild(source, file.name)
const destination = safeChild(stagingDirectory, file.name)
await copyFile(sourceFile, destination)
ensureModelOperationNotAborted(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
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(
stagingDirectory,
this.modelDirectory(entry.id)
)
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
detachExternalAbort()
this.operations.delete(entry.id)
if (stagingDirectory) {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
}
async exportArchive(
modelId: string,
destinationPath: string
): Promise<void> {
const entry = this.requireCatalogEntry(modelId)
await this.ensureRoot()
const installed = (await this.readInstalled()).find(
(model) => model.id === entry.id
)
if (!installed) {
throw new Error('只能导出已安装的语音模型')
}
if (!speechModelPackageMatches(entry, installed.files)) {
throw new Error('语音模型文件与当前模型目录不匹配')
}
const directory = this.modelDirectory(entry.id)
const files = []
for (const expected of entry.files) {
const recorded = installed.files.find(
(file) =>
file.name === expected.name && file.role === expected.role
)
if (
!recorded ||
recorded.size <= 0 ||
recorded.size > this.maxFileBytes
) {
throw new Error(`语音模型文件不可导出:${expected.name}`)
}
files.push({
name: expected.name,
role: expected.role,
size: recorded.size,
sha256: recorded.sha256
})
}
await exportModelArchive({
destinationPath,
sourceDirectory: directory,
descriptor: {
kind: 'speech',
modelId: entry.id,
displayName: entry.displayName,
files
}
})
}
async importArchive(
modelId: string,
archivePath: string
): Promise<InstalledSpeechModel> {
const entry = this.requireCatalogEntry(modelId)
const expectedTotal = getMaximumModelPackageBytes(entry.files)
const maximumTotalBytes = Math.min(
MAXIMUM_ARCHIVE_BYTES,
expectedTotal + ARCHIVE_OVERHEAD_BYTES
)
const operation = this.beginOperation(
entry.id,
'import',
expectedTotal
)
let stagingDirectory: string | undefined
try {
await this.ensureRoot()
await this.assertNotInstalled(entry.id)
stagingDirectory = await this.createStagingDirectory(entry.id)
operation.progress.phase = 'transferring'
const descriptor = await extractModelArchive({
archivePath,
destinationDirectory: stagingDirectory,
expectedKind: 'speech',
expectedModelId: entry.id,
expectedFiles: entry.files.map((file) => ({
name: file.name,
role: file.role
})),
maximumArchiveBytes: Math.min(
MAXIMUM_ARCHIVE_BYTES,
maximumTotalBytes + ARCHIVE_OVERHEAD_BYTES
),
maximumFileBytes: this.maxFileBytes,
maximumTotalBytes,
signal: operation.controller.signal,
onProgress: (completedBytes) => {
operation.progress.completedBytes = completedBytes
}
})
if (
descriptor.files.some(
(file) => file.size > this.maxFileBytes
) ||
!speechModelPackageMatches(entry, descriptor.files)
) {
throw new Error('语音模型 ZIP 与当前模型目录不匹配')
}
operation.progress.totalBytes = descriptor.files.reduce(
(total, file) => total + file.size,
0
)
operation.progress.phase = 'installing'
operation.progress.currentFile = null
const installed = installedSpeechModelSchema.parse({
id: entry.id,
displayName: entry.displayName,
source: 'local',
installedAt: new Date().toISOString(),
files: descriptor.files
})
await writeFile(
safeChild(stagingDirectory, MANIFEST_FILE_NAME),
`${JSON.stringify(installed, null, 2)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
ensureModelOperationNotAborted(operation.controller.signal)
await rename(stagingDirectory, this.modelDirectory(entry.id))
stagingDirectory = undefined
this.invalidateSelectedRuntimeModel()
return installed
} finally {
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 invalidateSelectedRuntimeModel(): void {
this.selectedRuntimeGeneration += 1
this.selectedRuntimeModel = undefined
}
private async resolveSelectedRuntimeModel(
selectedModelId: string,
generation: number
): Promise<CachedSelectedSpeechRuntimeModel | undefined> {
await this.ensureRoot()
const catalogEntry = this.catalog.find(
(entry) => entry.id === selectedModelId
)
if (!catalogEntry) {
return undefined
}
try {
const directory = this.modelDirectory(selectedModelId)
const manifestPath = safeChild(directory, MANIFEST_FILE_NAME)
const manifestFingerprintBefore = await fingerprintModelFile(
manifestPath
)
if (
!manifestFingerprintBefore.isFile ||
manifestFingerprintBefore.isSymbolicLink
) {
return undefined
}
const installed = installedSpeechModelSchema.parse(
JSON.parse(
await readFile(manifestPath, 'utf8')
) as unknown
)
const manifestFingerprint = await fingerprintModelFile(manifestPath)
if (
!modelFileFingerprintMatches(
manifestFingerprintBefore,
manifestFingerprint
)
) {
return undefined
}
if (installed.id !== selectedModelId) {
return undefined
}
if (
installed.files.length !== catalogEntry.files.length ||
!speechModelPackageMatches(catalogEntry, installed.files)
) {
return undefined
}
const fileFingerprints = new Map<string, ModelFileFingerprint>()
for (const file of installed.files) {
const path = safeChild(directory, file.name)
const fingerprintBefore = await fingerprintModelFile(path)
if (
!fingerprintBefore.isFile ||
fingerprintBefore.isSymbolicLink ||
fingerprintBefore.size !== BigInt(file.size)
) {
return undefined
}
const actual = await hashModelFile(path)
const fingerprint = await fingerprintModelFile(path)
if (
actual.size !== file.size ||
actual.sha256 !== file.sha256 ||
!modelFileFingerprintMatches(
fingerprintBefore,
fingerprint
)
) {
return undefined
}
fileFingerprints.set(file.name, fingerprint)
}
if (
generation !== this.selectedRuntimeGeneration ||
(await this.readSelection()) !== selectedModelId
) {
return undefined
}
return {
model: {
id: installed.id,
family: catalogEntry.family,
directory,
files: installed.files.map((file) => ({ ...file }))
},
manifestFingerprint,
fileFingerprints
}
} catch {
return undefined
}
}
private cloneSelectedRuntimeModel(
model: SelectedSpeechRuntimeModel
): SelectedSpeechRuntimeModel {
return {
...model,
files: model.files.map((file) => ({ ...file }))
}
}
private async selectedRuntimeFingerprintsMatch(
cached: CachedSelectedSpeechRuntimeModel
): Promise<boolean> {
try {
const manifestFingerprint = await fingerprintModelFile(
safeChild(cached.model.directory, MANIFEST_FILE_NAME)
)
if (
!manifestFingerprint.isFile ||
manifestFingerprint.isSymbolicLink ||
!modelFileFingerprintMatches(
manifestFingerprint,
cached.manifestFingerprint
)
) {
return false
}
for (const file of cached.model.files) {
const expected = cached.fileFingerprints.get(file.name)
if (!expected) {
return false
}
const actual = await fingerprintModelFile(
safeChild(cached.model.directory, file.name)
)
if (
!actual.isFile ||
actual.isSymbolicLink ||
actual.size !== BigInt(file.size) ||
!modelFileFingerprintMatches(actual, expected)
) {
return false
}
}
return true
} catch {
return false
}
}
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,
downloadSource?: ModelDownloadSource
): 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,
...(downloadSource ? { downloadSource } : {})
}
}
this.operations.set(modelId, operation)
return operation
}
private attachExternalSignal(
signal: AbortSignal | undefined,
controller: AbortController
): () => void {
return attachModelAbortSignal(signal, controller)
}
private async assertNotInstalled(modelId: string): Promise<void> {
try {
await lstat(this.modelDirectory(modelId))
throw new Error('语音模型已安装')
} catch (error) {
if (isMissingFileError(error)) {
return
}
throw error
}
}
private async createStagingDirectory(modelId: string): Promise<string> {
return createModelStagingDirectory(
this.rootDirectory,
modelId,
'模型路径超出受管目录'
)
}
private async downloadFile(
file: ResolvedModelArtifactFile<SpeechModelFileSpec['role']>,
destination: string,
operation: ActiveOperation,
signal: AbortSignal
): Promise<void> {
if (
file.size > this.maxFileBytes ||
file.size <= 0
) {
throw new RangeError(`模型文件大小超出限制:${file.name}`)
}
const response = await fetchModelDownloadResponse({
transport: this.transport,
initialUrl: file.target.url,
redirectHosts: file.target.redirectHosts,
signal,
modelLabel: '模型'
})
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.size
) {
await response.body.cancel().catch(() => undefined)
throw new Error(`模型文件大小不匹配:${file.name}`)
}
}
const partialPath = `${destination}${MODEL_PARTIAL_SUFFIX}`
const handle = await open(partialPath, 'wx')
const reader = response.body.getReader()
const hash = createHash('sha256')
let written = 0
try {
while (true) {
ensureModelOperationNotAborted(signal)
const result = await reader.read()
if (result.done) {
break
}
if (
written + result.value.byteLength > file.size ||
written + result.value.byteLength > this.maxFileBytes
) {
await reader.cancel()
throw new RangeError(`模型文件过大:${file.name}`)
}
const persistedBytes = await writeModelBuffer(
handle,
result.value,
(persisted) => {
hash.update(persisted)
operation.progress.completedBytes += persisted.byteLength
}
)
written += persistedBytes
}
} catch (error) {
await reader.cancel().catch(() => undefined)
throw error
} finally {
await handle.close()
}
if (written !== file.size) {
throw new Error(`模型文件大小不匹配:${file.name}`)
}
if (hash.digest('hex') !== file.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
})
const actualFiles: InstalledSpeechModel['files'] = []
for (const expectedFile of entry.files) {
ensureModelOperationNotAborted(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}`)
}
actualFiles.push({
name: expectedFile.name,
role: expectedFile.role,
...(await hashModelFile(sourceFile, signal))
})
}
if (!speechModelPackageMatches(entry, actualFiles)) {
throw new Error('本地模型文件与当前模型目录不匹配')
}
}
private async rejectUnsafeLocalEntries(
directory: string,
signal: AbortSignal,
counter: { visited: number }
): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries) {
ensureModelOperationNotAborted(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) {
ensureModelOperationNotAborted(signal)
const metadata = await hashModelFile(
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
})
if (!speechModelPackageMatches(entry, manifest.files)) {
throw new Error('语音模型文件与当前模型目录不匹配')
}
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 (isMissingFileError(error)) {
return null
}
return null
}
}
private async writeSelection(modelId: string | null): Promise<void> {
await this.ensureRoot()
const target = safeChild(this.rootDirectory, SELECTION_FILE_NAME)
const partialName =
`${SELECTION_FILE_NAME}.${randomUUID()}${MODEL_PARTIAL_SUFFIX}`
const partial = safeChild(
this.rootDirectory,
partialName
)
this.activeSelectionPartialNames.add(partialName)
try {
await this.selectionFileOperations.writeFile(
partial,
`${JSON.stringify(
selectionSchema.parse({ selectedModelId: modelId })
)}\n`,
{ encoding: 'utf8', flag: 'wx' }
)
await this.selectionFileOperations.rename(partial, target)
} catch (error) {
await rm(partial, { force: true })
throw error
} finally {
this.activeSelectionPartialNames.delete(partialName)
}
}
private cleanupStaleArtifacts(): Promise<void> {
return cleanupStaleModelInstallArtifacts({
rootDirectory: this.rootDirectory,
isModelId: (value) => speechModelIdSchema.safeParse(value).success,
activeModelIds: new Set(this.operations.keys()),
partialFileNames: new Set(
this.catalog.flatMap((entry) =>
entry.files.map((file) => file.name)
)
),
cleanSelectionPartials: true,
activeSelectionPartialNames: this.activeSelectionPartialNames,
escapeMessage: '模型路径超出受管目录'
})
}
}
export function createSpeechModelManager(
options: SpeechModelManagerOptions
): SpeechModelManager {
return new SpeechModelManager(options)
}