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 } export type SpeechModelManagerOptions = { userDataDirectory: string fetch: typeof fetch catalog?: readonly SpeechModelCatalogEntry[] getDownloadSource?: () => | ModelDownloadSource | Promise maxFileBytes?: number selectionFileOperations?: Partial } 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 private readonly maxFileBytes: number private readonly selectionFileOperations: SpeechSelectionFileOperations private readonly operations = new Map() private readonly activeSelectionPartialNames = new Set() 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { await mkdir(this.rootDirectory, { recursive: true }) } private invalidateSelectedRuntimeModel(): void { this.selectedRuntimeGeneration += 1 this.selectedRuntimeModel = undefined } private async resolveSelectedRuntimeModel( selectedModelId: string, generation: number ): Promise { 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() 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 { 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 { try { await lstat(this.modelDirectory(modelId)) throw new Error('语音模型已安装') } catch (error) { if (isMissingFileError(error)) { return } throw error } } private async createStagingDirectory(modelId: string): Promise { return createModelStagingDirectory( this.rootDirectory, modelId, '模型路径超出受管目录' ) } private async downloadFile( file: ResolvedModelArtifactFile, destination: string, operation: ActiveOperation, signal: AbortSignal ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) }