mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
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:
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"}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user