simplify speech bootstrap and clean daemon startup locking
This commit is contained in:
@@ -63,7 +63,7 @@ import type {
|
||||
AgentProvider,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import { acquirePidLock, releasePidLock, isLocked, PidLockError } from "./pid-lock.js";
|
||||
import { acquirePidLock, releasePidLock } from "./pid-lock.js";
|
||||
import { isHostAllowed, type AllowedHostsConfig } from "./allowed-hosts.js";
|
||||
import {
|
||||
createVoiceMcpSocketBridgeManager,
|
||||
@@ -137,16 +137,6 @@ export async function createPaseoDaemon(
|
||||
): Promise<PaseoDaemon> {
|
||||
const logger = rootLogger.child({ module: "bootstrap" });
|
||||
|
||||
// Fail fast before expensive bootstrap (speech/runtime/model checks) if another daemon is active.
|
||||
const lockState = await isLocked(config.paseoHome);
|
||||
if (lockState.locked) {
|
||||
const existingLock = lockState.info;
|
||||
throw new PidLockError(
|
||||
`Another Paseo daemon is already running (PID ${existingLock?.pid ?? "unknown"}, started ${existingLock?.startedAt ?? "unknown"})`,
|
||||
existingLock
|
||||
);
|
||||
}
|
||||
|
||||
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
|
||||
await acquirePidLock(config.paseoHome, config.listen);
|
||||
|
||||
|
||||
@@ -186,7 +186,8 @@ describe("ConnectionOfferV2 (daemon E2E)", () => {
|
||||
PASEO_HOME: tempHome,
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
OPENAI_API_KEY: "",
|
||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_LOG_FORMAT: "json",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
|
||||
@@ -22,7 +22,6 @@ const ProviderCredentialsSchema = z
|
||||
const LocalSpeechProviderSchema = z
|
||||
.object({
|
||||
modelsDir: z.string().min(1).optional(),
|
||||
autoDownload: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -175,6 +174,34 @@ function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined {
|
||||
return logger?.child({ module: "config" });
|
||||
}
|
||||
|
||||
function stripDeprecatedLocalSpeechConfigFields(parsed: unknown): unknown {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const root = { ...(parsed as Record<string, unknown>) };
|
||||
const providers = root.providers;
|
||||
if (!providers || typeof providers !== "object" || Array.isArray(providers)) {
|
||||
return root;
|
||||
}
|
||||
|
||||
const providersRecord = { ...(providers as Record<string, unknown>) };
|
||||
const local = providersRecord.local;
|
||||
if (!local || typeof local !== "object" || Array.isArray(local)) {
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
|
||||
const localRecord = { ...(local as Record<string, unknown>) };
|
||||
if ("autoDownload" in localRecord) {
|
||||
delete localRecord.autoDownload;
|
||||
}
|
||||
|
||||
providersRecord.local = localRecord;
|
||||
root.providers = providersRecord;
|
||||
return root;
|
||||
}
|
||||
|
||||
export function loadPersistedConfig(
|
||||
paseoHome: string,
|
||||
logger?: LoggerLike
|
||||
@@ -209,7 +236,8 @@ export function loadPersistedConfig(
|
||||
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
|
||||
}
|
||||
|
||||
const result = PersistedConfigSchema.safeParse(parsed);
|
||||
const migrated = stripDeprecatedLocalSpeechConfigFields(parsed);
|
||||
const result = PersistedConfigSchema.safeParse(migrated);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
|
||||
@@ -2879,7 +2879,6 @@ export class Session {
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.emit({
|
||||
|
||||
@@ -24,7 +24,6 @@ export type LocalSpeechModelConfig = {
|
||||
|
||||
export type LocalSpeechProviderConfig = {
|
||||
modelsDir: string;
|
||||
autoDownload?: boolean;
|
||||
models: LocalSpeechModelConfig;
|
||||
};
|
||||
|
||||
@@ -36,17 +35,6 @@ export type { LocalSpeechModelId, LocalSttModelId, LocalTtsModelId };
|
||||
|
||||
const DEFAULT_LOCAL_MODELS_SUBDIR = path.join("models", "local-speech");
|
||||
|
||||
const BooleanStringSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.pipe(z.enum(["1", "0", "true", "false", "yes", "no"]))
|
||||
.transform((value) => value === "1" || value === "true" || value === "yes");
|
||||
|
||||
const OptionalBooleanFlagSchema = z
|
||||
.union([z.boolean(), BooleanStringSchema])
|
||||
.optional();
|
||||
|
||||
const NumberLikeSchema = z.union([
|
||||
z.number(),
|
||||
z.string().trim().min(1),
|
||||
@@ -63,7 +51,6 @@ const OptionalIntegerSchema = NumberLikeSchema
|
||||
const LocalSpeechResolutionSchema = z.object({
|
||||
includeProviderConfig: z.boolean(),
|
||||
modelsDir: z.string().trim().min(1),
|
||||
autoDownload: OptionalBooleanFlagSchema.default(true),
|
||||
dictationLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalSttModel: LocalSttModelIdSchema.default(DEFAULT_LOCAL_STT_MODEL),
|
||||
voiceLocalTtsModel: LocalTtsModelIdSchema.default(DEFAULT_LOCAL_TTS_MODEL),
|
||||
@@ -98,7 +85,7 @@ function shouldIncludeLocalProviderConfig(params: {
|
||||
return (
|
||||
localRequestedByFeature ||
|
||||
params.env.PASEO_LOCAL_MODELS_DIR !== undefined ||
|
||||
params.persisted.providers?.local !== undefined
|
||||
params.persisted.providers?.local?.modelsDir !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,9 +103,6 @@ export function resolveLocalSpeechConfig(params: {
|
||||
params.env.PASEO_LOCAL_MODELS_DIR ??
|
||||
params.persisted.providers?.local?.modelsDir ??
|
||||
path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
|
||||
autoDownload:
|
||||
params.env.PASEO_LOCAL_AUTO_DOWNLOAD ??
|
||||
params.persisted.providers?.local?.autoDownload,
|
||||
dictationLocalSttModel:
|
||||
params.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
persistedLocalFeatureModel(
|
||||
@@ -160,7 +144,6 @@ export function resolveLocalSpeechConfig(params: {
|
||||
parsed.includeProviderConfig
|
||||
? {
|
||||
modelsDir: parsed.modelsDir,
|
||||
autoDownload: parsed.autoDownload,
|
||||
models: {
|
||||
dictationStt: parsed.dictationLocalSttModel,
|
||||
voiceStt: parsed.voiceLocalSttModel,
|
||||
|
||||
@@ -36,13 +36,11 @@ export function getLocalSpeechModelDir(modelsDir: string, modelId: LocalSpeechMo
|
||||
export async function ensureLocalSpeechModels(options: {
|
||||
modelsDir: string;
|
||||
modelIds: LocalSpeechModelId[];
|
||||
autoDownload?: boolean;
|
||||
logger: import("pino").Logger;
|
||||
}): Promise<Record<LocalSpeechModelId, string>> {
|
||||
return ensureSherpaOnnxModels({
|
||||
modelsDir: options.modelsDir,
|
||||
modelIds: options.modelIds,
|
||||
autoDownload: options.autoDownload ?? true,
|
||||
logger: options.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ type ResolvedLocalModels = {
|
||||
type LocalSpeechAvailability = {
|
||||
configured: boolean;
|
||||
modelsDir: string | null;
|
||||
autoDownload: boolean | null;
|
||||
};
|
||||
|
||||
export type InitializedLocalSpeech = {
|
||||
@@ -79,7 +78,6 @@ export function getLocalSpeechAvailability(
|
||||
return {
|
||||
configured: Boolean(localConfig),
|
||||
modelsDir: localConfig?.modelsDir ?? null,
|
||||
autoDownload: localConfig?.autoDownload ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,12 +184,10 @@ export async function initializeLocalSpeechServices(params: {
|
||||
providers: RequestedSpeechProviders;
|
||||
speechConfig: PaseoSpeechConfig | null;
|
||||
logger: Logger;
|
||||
modelEnsureAutoDownload?: boolean;
|
||||
}): Promise<InitializedLocalSpeech> {
|
||||
const { providers, logger, speechConfig } = params;
|
||||
const localConfig = speechConfig?.local ?? null;
|
||||
const localModels = resolveConfiguredLocalModels(speechConfig);
|
||||
const modelEnsureAutoDownload = params.modelEnsureAutoDownload ?? (localConfig?.autoDownload ?? true);
|
||||
|
||||
let sttService: SpeechToTextProvider | null = null;
|
||||
let ttsService: TextToSpeechProvider | null = null;
|
||||
@@ -209,28 +205,25 @@ export async function initializeLocalSpeechServices(params: {
|
||||
{
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
},
|
||||
"Ensuring local speech models"
|
||||
);
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
autoDownload: modelEnsureAutoDownload,
|
||||
hint:
|
||||
"Use `paseo speech models` to inspect status and " +
|
||||
"`paseo speech download --model <MODEL_ID>` to fetch missing models.",
|
||||
},
|
||||
"Failed to ensure local speech models"
|
||||
"Local speech model bootstrap failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,14 +249,14 @@ export async function initializeLocalSpeechServices(params: {
|
||||
localSttEngines.set(modelId, created);
|
||||
return created;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelId,
|
||||
hint: buildModelDownloadHint(modelId),
|
||||
},
|
||||
"Failed to initialize local STT engine (models missing or invalid)"
|
||||
"Local STT engine unavailable"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@@ -340,14 +333,14 @@ export async function initializeLocalSpeechServices(params: {
|
||||
}
|
||||
ttsService = localVoiceTtsProvider;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelId: localModels.voiceLocalTtsModel,
|
||||
hint: buildModelDownloadHint(localModels.voiceLocalTtsModel),
|
||||
},
|
||||
"Failed to initialize local TTS engine (models missing or invalid)"
|
||||
"Local TTS engine unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,25 +35,12 @@ describe("sherpa model downloader", () => {
|
||||
const out = await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "kitten-nano-en-v0_1-fp16",
|
||||
autoDownload: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(out).toBe(modelDir);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel throws when missing and autoDownload is false", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
await expect(
|
||||
ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "zipformer-bilingual-zh-en-2023-02-20",
|
||||
autoDownload: false,
|
||||
logger,
|
||||
})
|
||||
).rejects.toThrow(/auto-download/i);
|
||||
});
|
||||
|
||||
test("ensureSherpaOnnxModel logs lifecycle events without progress spam", async () => {
|
||||
const modelsDir = makeTmpDir();
|
||||
const infoMessages: string[] = [];
|
||||
@@ -82,7 +69,6 @@ describe("sherpa model downloader", () => {
|
||||
await ensureSherpaOnnxModel({
|
||||
modelsDir,
|
||||
modelId: "pocket-tts-onnx-int8",
|
||||
autoDownload: true,
|
||||
logger: loggerWithSpy,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getSherpaOnnxModelSpec, type SherpaOnnxModelId } from "./model-catalog.
|
||||
export type EnsureSherpaOnnxModelOptions = {
|
||||
modelsDir: string;
|
||||
modelId: SherpaOnnxModelId;
|
||||
autoDownload: boolean;
|
||||
logger: pino.Logger;
|
||||
};
|
||||
|
||||
@@ -104,13 +103,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
return modelDir;
|
||||
}
|
||||
|
||||
if (!options.autoDownload) {
|
||||
throw new Error(
|
||||
`Missing local speech model files for ${options.modelId} in ${modelDir}. ` +
|
||||
`Set PASEO_LOCAL_AUTO_DOWNLOAD=1 to auto-download.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info({ modelsDir: options.modelsDir }, "Starting model download");
|
||||
|
||||
try {
|
||||
@@ -207,7 +199,6 @@ export async function ensureSherpaOnnxModel(options: EnsureSherpaOnnxModelOption
|
||||
export async function ensureSherpaOnnxModels(options: {
|
||||
modelsDir: string;
|
||||
modelIds: SherpaOnnxModelId[];
|
||||
autoDownload: boolean;
|
||||
logger: pino.Logger;
|
||||
}): Promise<Record<SherpaOnnxModelId, string>> {
|
||||
const uniq = Array.from(new Set(options.modelIds));
|
||||
@@ -216,7 +207,6 @@ export async function ensureSherpaOnnxModels(options: {
|
||||
out[id] = await ensureSherpaOnnxModel({
|
||||
modelsDir: options.modelsDir,
|
||||
modelId: id,
|
||||
autoDownload: options.autoDownload,
|
||||
logger: options.logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ describe("speech models (download E2E)", () => {
|
||||
await ensureSherpaOnnxModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -121,7 +120,6 @@ describe("speech models (download E2E)", () => {
|
||||
},
|
||||
local: {
|
||||
modelsDir,
|
||||
autoDownload: false,
|
||||
models: {
|
||||
dictationStt:
|
||||
set === "parakeet-pocket"
|
||||
|
||||
@@ -35,7 +35,6 @@ describe("resolveSpeechConfig", () => {
|
||||
});
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: path.join(paseoHome, "models", "local-speech"),
|
||||
autoDownload: true,
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v2-int8",
|
||||
@@ -67,7 +66,6 @@ describe("resolveSpeechConfig", () => {
|
||||
PASEO_VOICE_LOCAL_TTS_SPEAKER_ID: "5",
|
||||
PASEO_VOICE_LOCAL_TTS_SPEED: "1.35",
|
||||
PASEO_LOCAL_MODELS_DIR: "/tmp/models",
|
||||
PASEO_LOCAL_AUTO_DOWNLOAD: "0",
|
||||
OPENAI_API_KEY: "env-key",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_DICTATION_STT_PROVIDER: "local",
|
||||
@@ -82,7 +80,6 @@ describe("resolveSpeechConfig", () => {
|
||||
|
||||
expect(result.speech.local).toEqual({
|
||||
modelsDir: "/tmp/models",
|
||||
autoDownload: false,
|
||||
models: {
|
||||
dictationStt: "zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceStt: "parakeet-tdt-0.6b-v3-int8",
|
||||
|
||||
@@ -79,7 +79,6 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
availability: {
|
||||
configured: false,
|
||||
modelsDir: null,
|
||||
autoDownload: null,
|
||||
},
|
||||
cleanup: () => {},
|
||||
});
|
||||
@@ -115,7 +114,6 @@ describe("initializeSpeechRuntime readiness", () => {
|
||||
availability: {
|
||||
configured: false,
|
||||
modelsDir: null,
|
||||
autoDownload: null,
|
||||
},
|
||||
cleanup: () => {},
|
||||
});
|
||||
|
||||
@@ -205,7 +205,6 @@ function buildVoiceFeatureReadiness(params: {
|
||||
missingLocalModelIds: LocalSpeechModelId[];
|
||||
backgroundDownloadInProgress: boolean;
|
||||
backgroundDownloadError: string | null;
|
||||
localAutoDownloadEnabled: boolean;
|
||||
}): SpeechReadinessState {
|
||||
const enabled = params.realtimeVoice.enabled || params.dictation.enabled;
|
||||
if (!enabled) {
|
||||
@@ -246,7 +245,7 @@ function buildVoiceFeatureReadiness(params: {
|
||||
available: false,
|
||||
reasonCode: "models_missing",
|
||||
message: `Voice features are unavailable: missing local models (${joinModelIds(missingModelIds)}).`,
|
||||
retryable: params.localAutoDownloadEnabled,
|
||||
retryable: true,
|
||||
missingModelIds,
|
||||
};
|
||||
}
|
||||
@@ -328,7 +327,6 @@ export async function initializeSpeechRuntime(params: {
|
||||
const openaiConfig = params.openaiConfig;
|
||||
const providers = resolveRequestedSpeechProviders(speechConfig);
|
||||
const requestedProviders = describeRequestedProviders(providers);
|
||||
const localAutoDownloadEnabled = speechConfig?.local?.autoDownload ?? true;
|
||||
|
||||
validateOpenAiCredentialRequirements({
|
||||
providers,
|
||||
@@ -382,7 +380,6 @@ export async function initializeSpeechRuntime(params: {
|
||||
missingLocalModelIds,
|
||||
backgroundDownloadInProgress,
|
||||
backgroundDownloadError,
|
||||
localAutoDownloadEnabled,
|
||||
});
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -459,12 +456,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
});
|
||||
};
|
||||
|
||||
const reconcileServices = async (modelEnsureAutoDownload: boolean): Promise<void> => {
|
||||
const reconcileServices = async (): Promise<void> => {
|
||||
const nextLocalSpeech = await initializeLocalSpeechServices({
|
||||
providers,
|
||||
speechConfig,
|
||||
logger,
|
||||
modelEnsureAutoDownload,
|
||||
});
|
||||
const nextOpenAiSpeech = initializeOpenAiSpeechServices({
|
||||
providers,
|
||||
@@ -521,13 +517,13 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
const runReconcile = async (params: { modelEnsureAutoDownload: boolean }): Promise<void> => {
|
||||
const runReconcile = async (): Promise<void> => {
|
||||
if (reconcileInFlight) {
|
||||
await reconcileInFlight;
|
||||
publishReadinessIfChanged();
|
||||
return;
|
||||
}
|
||||
reconcileInFlight = reconcileServices(params.modelEnsureAutoDownload).finally(() => {
|
||||
reconcileInFlight = reconcileServices().finally(() => {
|
||||
reconcileInFlight = null;
|
||||
});
|
||||
await reconcileInFlight;
|
||||
@@ -550,7 +546,7 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
const modelsDir = localModelConfig?.modelsDir ?? null;
|
||||
const modelIds = [...missingLocalModelIds];
|
||||
if (!modelsDir || modelIds.length === 0 || !localAutoDownloadEnabled) {
|
||||
if (!modelsDir || modelIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,10 +567,9 @@ export async function initializeSpeechRuntime(params: {
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir,
|
||||
modelIds,
|
||||
autoDownload: true,
|
||||
logger,
|
||||
});
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
backgroundDownloadError = null;
|
||||
} catch (error) {
|
||||
backgroundDownloadError = error instanceof Error ? error.message : String(error);
|
||||
@@ -610,12 +605,11 @@ export async function initializeSpeechRuntime(params: {
|
||||
missingLocalModelIds.length === 0 &&
|
||||
!backgroundDownloadInProgress
|
||||
) {
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
}
|
||||
|
||||
if (
|
||||
missingLocalModelIds.length > 0 &&
|
||||
localAutoDownloadEnabled &&
|
||||
!backgroundDownloadInProgress &&
|
||||
!backgroundDownloadError
|
||||
) {
|
||||
@@ -629,10 +623,10 @@ export async function initializeSpeechRuntime(params: {
|
||||
}
|
||||
};
|
||||
|
||||
await runReconcile({ modelEnsureAutoDownload: false });
|
||||
await runReconcile();
|
||||
const snapshot = computeReadinessSnapshot();
|
||||
if (snapshot.voiceFeature.enabled && !snapshot.voiceFeature.available) {
|
||||
if (missingLocalModelIds.length > 0 && localAutoDownloadEnabled) {
|
||||
if (missingLocalModelIds.length > 0) {
|
||||
startBackgroundDownload();
|
||||
}
|
||||
scheduleMonitor();
|
||||
|
||||
Reference in New Issue
Block a user