feat(models): route manual imports with remote assets through the gallery job queue

Manual /models/import with downloadable assets ran synchronously; it now
enqueues like a gallery install and the import UI polls the job progress.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
(cherry picked from commit 7420e2e71b)
This commit is contained in:
Plamen K. Kosseff authored and Ettore Di Giacinto committed 2026-10-13 08:14:30 +00:00
1 parent 5cde0168c2
commit 28c4bb9277
4 files changed
+79 -15

No files matched your search

@@ -183,7 +183,7 @@ var _ = Describe("Edit Model test", func() {
app := echo.New()
// Set up a simple renderer for the test
app.Renderer = &testRenderer{}
app.POST("/import-model", ImportModelEndpoint(modelConfigLoader, nil, applicationConfig))
app.POST("/import-model", ImportModelEndpoint(modelConfigLoader, nil, applicationConfig, nil))
app.GET("/edit-model/:name", GetEditModelPage(modelConfigLoader, applicationConfig))
requestBody := bytes.NewBufferString(`{"name": "foo", "backend": "foo", "model": "foo"}`)
+51 -1
View File
@@ -125,7 +125,7 @@ func ImportModelURIEndpoint(cl *config.ModelConfigLoader, appConfig *config.Appl
}
// ImportModelEndpoint handles creating new model configurations
func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryService, appConfig *config.ApplicationConfig) echo.HandlerFunc {
func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryService, appConfig *config.ApplicationConfig, opcache *galleryop.OpCache) echo.HandlerFunc {
return func(c echo.Context) error {
// Get the raw body
body, err := io.ReadAll(c.Request().Body)
@@ -226,6 +226,37 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryServ
return c.JSON(http.StatusInternalServerError, response)
}
// A config referencing remote assets is a download, not just a file
// write: route it through the gallery op queue so the UI tracks it
// like a gallery install (job UUID + /models/jobs/{uuid}), instead
// of downloading synchronously inside this request via Preload. The
// op writes the config file and reloads once the downloads finish;
// configs with no remote assets keep the fast synchronous path.
if files := remoteAssetFiles(&modelConfig); len(files) > 0 && gs != nil {
jobUUID, err := uuid.NewUUID()
if err != nil {
return err
}
if opcache != nil {
opcache.Set(modelConfig.Name, jobUUID.String())
}
gs.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Req: gallery.GalleryModel{Overrides: map[string]any{}},
ID: jobUUID.String(),
GalleryElementName: modelConfig.Name,
GalleryElement: &gallery.ModelConfig{
Name: modelConfig.Name,
ConfigFile: string(yamlData),
Files: files,
},
BackendGalleries: appConfig.BackendGalleries,
})
return c.JSON(200, schema.GalleryResponse{
ID: jobUUID.String(),
StatusURL: fmt.Sprintf("%smodels/jobs/%s", httpUtils.BaseURL(c), jobUUID.String()),
})
}
// Write the file
if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
response := ModelResponse{
@@ -267,3 +298,22 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryServ
return c.JSON(200, response)
}
}
// remoteAssetFiles lists the downloads a config implies: explicit
// download_files entries, and URI-valued model/mmproj fields under the
// same on-disk names the config loader's preload would give them — so
// the later preload finds the files present and the config needs no
// rewriting.
func remoteAssetFiles(c *config.ModelConfig) []gallery.File {
var files []gallery.File
for _, f := range c.DownloadFiles {
files = append(files, gallery.File{Filename: f.Filename, SHA256: f.SHA256, URI: string(f.URI)})
}
if c.IsModelURL() {
files = append(files, gallery.File{Filename: c.ModelFileName(), URI: c.Model})
}
if c.IsMMProjURL() {
files = append(files, gallery.File{Filename: c.MMProjFileName(), URI: c.MMProj})
}
return files
}
+26 -12
View File
@@ -280,16 +280,6 @@ export default function ImportModel() {
pollRef.current = setInterval(async () => {
try {
const data = await modelsApi.getJobStatus(jobId)
if (data.completed) {
transferRateRef.current.reset(jobId)
clearInterval(pollRef.current)
pollRef.current = null
setIsSubmitting(false)
setJob(null)
setDone({ name: data.gallery_element_name || '' })
addToast(t('toasts.imported'), 'success')
return
}
if (data.error || (data.message && data.message.startsWith('error:'))) {
transferRateRef.current.reset(jobId)
clearInterval(pollRef.current)
@@ -304,6 +294,20 @@ export default function ImportModel() {
addToast(t('toasts.importFailed', { message: msg }), 'error')
return
}
// The raw /models/jobs endpoint reports `processed`, not the
// `completed` the /api/models/job shim synthesizes — checking
// only `completed` left the poll spinning after the job ended.
// Errors are handled above: failed jobs also set `processed`.
if (data.completed || data.processed) {
transferRateRef.current.reset(jobId)
clearInterval(pollRef.current)
pollRef.current = null
setIsSubmitting(false)
setJob(null)
setDone({ name: data.gallery_element_name || '' })
addToast(t('toasts.imported'), 'success')
return
}
// Keep the whole status. /api/operations carries the same job (the
// import endpoint registers it in the opcache) but drops it the moment
// it finishes, which is indistinguishable from a cancel — so terminal
@@ -408,12 +412,22 @@ export default function ImportModel() {
if (!yamlContent.trim()) { addToast(t('toasts.noYaml'), 'error'); return }
setIsSubmitting(true)
try {
await modelsApi.importConfig(yamlContent, 'application/x-yaml')
const result = await modelsApi.importConfig(yamlContent, 'application/x-yaml')
// A config referencing remote assets comes back as a queued job
// (same shape as the URI import) and startJobPolling owns the
// submitting state until the job ends; a config-only import
// completes synchronously and navigates straight to the model list.
const jobId = result?.uuid || result?.ID
if (jobId) {
addToast(t('toasts.started'), 'success')
startJobPolling(jobId)
return
}
addToast(t('toasts.importedYaml'), 'success')
navigate('/app/models?view=installed')
setIsSubmitting(false)
} catch (err) {
addToast(t('toasts.importFailed', { message: err.message }), 'error')
} finally {
setIsSubmitting(false)
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
router.POST("/backends/upgrades/check", backendGalleryEndpointService.CheckUpgradesEndpoint(), adminMiddleware)
router.POST("/backends/upgrade/:name", backendGalleryEndpointService.UpgradeBackendEndpoint(), adminMiddleware)
// Custom model import endpoint
router.POST("/models/import", localai.ImportModelEndpoint(cl, galleryService, appConfig), adminMiddleware)
router.POST("/models/import", localai.ImportModelEndpoint(cl, galleryService, appConfig, opcache), adminMiddleware)
// URI model import endpoint
router.POST("/models/import-uri", localai.ImportModelURIEndpoint(cl, appConfig, galleryService, opcache), adminMiddleware)