Compare commits

...
Author SHA1 Message Date
ssongliu 30381a3c68 feat(ai): streamline deployments and add local model selection (#14293)
* feat(ai): split model management and add weight provider filters

* feat(ai): streamline deployments and add local model selection
2026-11-03 23:54:22 +08:00
Kadxy 37170ae81a fix(openresty): report brotli as pending when the http.d include can be added (#14285)
Installs that enabled ngx_brotli before v2.3.0 have no http.d include in
nginx.conf, because the include is only inserted when a module is toggled
or built. The settings page reported this as "cannot write the managed
configuration" and skipped the brotli save, so the save path that inserts
the include was never reached, even on a stock nginx.conf.

The settings response now tells the two cases apart with an in-memory
insertion check that writes nothing:

- include missing but insertable, or managed file missing: managedPending,
  the page explains the values take effect after saving and saves normally
- no safe insertion point: managedUnavailable, unchanged

The save path now inserts the include through ensureNginxHTTPIncludeActive,
the same function module activation uses, so both produce identical
results. A sentinel error keeps "no insertion point" mapped to
ErrBrotliUnsupported while I/O failures are still returned as-is.
2026-11-03 18:58:24 +08:00
46 changed files with 943 additions and 1013 deletions
+9
View File
@@ -305,6 +305,15 @@ func (b *BaseApi) PageLocalDownloadedModels(c *gin.Context) {
helper.SuccessWithData(c, dto.PageResult{Items: items, Total: total})
}
func (b *BaseApi) ListLocalModelOptions(c *gin.Context) {
items, err := modelDownloaderService.ListLocalModelOptions()
if err != nil {
handleModelDownloaderError(c, err)
return
}
helper.SuccessWithData(c, items)
}
// @Tags AI
// @Summary Delete locally downloaded model
// @Accept json
+12 -3
View File
@@ -92,6 +92,12 @@ type ModelDownloadTaskItem struct {
CanRemoveRecord bool `json:"canRemoveRecord"`
}
type LocalModelOption struct {
Name string `json:"name"`
Organization string `json:"organization"`
Path string `json:"path"`
}
type LocalModelSearch struct {
DownloadType string `json:"downloadType" validate:"omitempty,oneof=all tokenizer"`
PageInfo
@@ -121,6 +127,7 @@ type FeaturedModel struct {
Sort int `json:"sort" yaml:"sort"`
Family string `json:"family" yaml:"category"`
RepoID string `json:"repoID" yaml:"name"`
WeightProvider string `json:"weightProvider" yaml:"weightProvider"`
Sources []string `json:"sources" yaml:"sources"`
SourceModelIDs map[string]string `json:"sourceModelIds" yaml:"sourceModelIds"`
Architecture string `json:"architecture" yaml:"modelType"`
@@ -133,11 +140,13 @@ type FeaturedModel struct {
type FeaturedModelSearch struct {
PageInfo
Family string `json:"family"`
Info string `json:"info"`
Family string `json:"family"`
WeightProvider string `json:"weightProvider"`
Info string `json:"info"`
}
type FeaturedModelPage struct {
PageResult
Families []string `json:"families"`
Families []string `json:"families"`
WeightProviders []string `json:"weightProviders"`
}
+5 -1
View File
@@ -20,11 +20,15 @@ type NginxParam struct {
// NginxBrotliRes carries the brotli settings together with where they live.
// ManagedExternally is true when the user defined brotli by hand, in which
// case the panel only reports the values and must not write its own copy.
// ManagedUnavailable is true when the panel could not wire the managed
// ManagedPending is true when nginx does not load the managed configuration
// yet but saving will wire it in, so the reported values are not in effect
// until then.
// ManagedUnavailable is true when the panel cannot wire the managed
// configuration into nginx.conf at all, so the reported values are inert.
type NginxBrotliRes struct {
Params []NginxParam `json:"params"`
ManagedExternally bool `json:"managedExternally"`
ManagedPending bool `json:"managedPending"`
ManagedUnavailable bool `json:"managedUnavailable"`
}
+1
View File
@@ -6,6 +6,7 @@ type FeaturedModel struct {
Sort int `json:"sort"`
Family string `json:"family"`
RepoID string `json:"repoID"`
WeightProvider string `json:"weightProvider"`
Sources []string `json:"sources" gorm:"serializer:json"`
SourceModelIDs map[string]string `json:"sourceModelIds" gorm:"serializer:json"`
Architecture string `json:"architecture"`
+12 -2
View File
@@ -11,8 +11,9 @@ import (
type FeaturedModelRepo struct{}
type IFeaturedModelRepo interface {
Page(page, size int, family, info string) (int64, []model.FeaturedModel, error)
Page(page, size int, family, weightProvider, info string) (int64, []model.FeaturedModel, error)
Families() ([]string, error)
WeightProviders() ([]string, error)
SyncAll(models []model.FeaturedModel, version string) error
}
@@ -20,13 +21,16 @@ func NewIFeaturedModelRepo() IFeaturedModelRepo {
return &FeaturedModelRepo{}
}
func (r *FeaturedModelRepo) Page(page, size int, family, info string) (int64, []model.FeaturedModel, error) {
func (r *FeaturedModelRepo) Page(page, size int, family, weightProvider, info string) (int64, []model.FeaturedModel, error) {
var total int64
var items []model.FeaturedModel
db := global.DB.Model(&model.FeaturedModel{})
if family != "" {
db = db.Where("family = ?", family)
}
if weightProvider != "" {
db = db.Where("weight_provider = ?", weightProvider)
}
if info != "" {
db = db.Where("LOWER(repo_id) LIKE ?", "%"+strings.ToLower(info)+"%")
}
@@ -45,6 +49,12 @@ func (r *FeaturedModelRepo) Families() ([]string, error) {
return families, err
}
func (r *FeaturedModelRepo) WeightProviders() ([]string, error) {
providers := make([]string, 0)
err := global.DB.Model(&model.FeaturedModel{}).Where("weight_provider <> ?", "").Distinct("weight_provider").Order("weight_provider asc").Pluck("weight_provider", &providers).Error
return providers, err
}
func (r *FeaturedModelRepo) SyncAll(models []model.FeaturedModel, version string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("1 = 1").Delete(&model.FeaturedModel{}).Error; err != nil {
+16 -6
View File
@@ -19,7 +19,7 @@ import (
var featuredModelsSyncMu sync.Mutex
func (m *ModelDownloaderService) PageFeaturedModels(req dto.FeaturedModelSearch) (*dto.FeaturedModelPage, error) {
total, models, err := featuredModelRepo.Page(req.Page, req.PageSize, strings.TrimSpace(req.Family), strings.TrimSpace(req.Info))
total, models, err := featuredModelRepo.Page(req.Page, req.PageSize, strings.TrimSpace(req.Family), strings.TrimSpace(req.WeightProvider), strings.TrimSpace(req.Info))
if err != nil {
return nil, err
}
@@ -31,8 +31,8 @@ func (m *ModelDownloaderService) PageFeaturedModels(req dto.FeaturedModelSearch)
}
items = append(items, dto.FeaturedModel{
Key: item.Key, Sort: item.Sort, Family: item.Family, RepoID: item.RepoID, Sources: item.Sources,
SourceModelIDs: item.SourceModelIDs,
Architecture: item.Architecture, BackboneParams: item.BackboneParams,
SourceModelIDs: item.SourceModelIDs, WeightProvider: item.WeightProvider,
Architecture: item.Architecture, BackboneParams: item.BackboneParams,
ActiveParams: item.ActiveParams, Engram: item.Engram, WeightGB: item.WeightGB,
Description: description,
})
@@ -41,7 +41,11 @@ func (m *ModelDownloaderService) PageFeaturedModels(req dto.FeaturedModelSearch)
if err != nil {
return nil, err
}
return &dto.FeaturedModelPage{PageResult: dto.PageResult{Total: total, Items: items}, Families: families}, nil
weightProviders, err := featuredModelRepo.WeightProviders()
if err != nil {
return nil, err
}
return &dto.FeaturedModelPage{PageResult: dto.PageResult{Total: total, Items: items}, Families: families, WeightProviders: weightProviders}, nil
}
func (m *ModelDownloaderService) SyncFeaturedModels() error {
@@ -74,6 +78,12 @@ func (m *ModelDownloaderService) SyncFeaturedModels() error {
item.Key = strings.TrimSpace(item.Key)
item.Family = strings.TrimSpace(item.Family)
item.RepoID = strings.TrimSpace(item.RepoID)
item.WeightProvider = strings.TrimSpace(item.WeightProvider)
if item.WeightProvider == "" {
if provider, _, ok := strings.Cut(item.RepoID, "/"); ok {
item.WeightProvider = provider
}
}
if item.Key == "" || item.Family == "" || item.RepoID == "" || (item.Architecture != "MoE" && item.Architecture != "Dense") || item.WeightGB < 0 {
return fmt.Errorf("invalid featured model: %s", item.Key)
}
@@ -95,8 +105,8 @@ func (m *ModelDownloaderService) SyncFeaturedModels() error {
}
models = append(models, model.FeaturedModel{
Key: item.Key, Sort: item.Sort, Family: item.Family, RepoID: item.RepoID, Sources: item.Sources,
SourceModelIDs: item.SourceModelIDs,
Architecture: item.Architecture, BackboneParams: item.BackboneParams,
SourceModelIDs: item.SourceModelIDs, WeightProvider: item.WeightProvider,
Architecture: item.Architecture, BackboneParams: item.BackboneParams,
ActiveParams: item.ActiveParams, Engram: item.Engram, WeightGB: item.WeightGB,
Description: string(description),
})
+5
View File
@@ -59,6 +59,7 @@ type IModelDownloaderService interface {
RemoveTask(req dto.ModelDownloadTaskOperate) error
RecoverInterruptedTasks() error
ListLocalModels(req dto.LocalModelSearch) (int64, []dto.LocalModelItem, error)
ListLocalModelOptions() ([]dto.LocalModelOption, error)
DeleteLocalModel(req dto.LocalModelDelete) error
}
@@ -393,6 +394,10 @@ func (m *ModelDownloaderService) ListLocalModels(req dto.LocalModelSearch) (int6
return int64(len(items)), pageLocalModels(items, req.Page, req.PageSize), nil
}
func (m *ModelDownloaderService) ListLocalModelOptions() ([]dto.LocalModelOption, error) {
return listLocalModelOptions(loadModelDownloaderDir())
}
func (m *ModelDownloaderService) DeleteLocalModel(req dto.LocalModelDelete) error {
if req.Name != strings.TrimSpace(req.Name) {
return newModelDownloaderInputError("invalid model directory")
@@ -0,0 +1,63 @@
package service
import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
)
func listLocalModelOptions(root string) ([]dto.LocalModelOption, error) {
items := make([]dto.LocalModelOption, 0)
entries, err := os.ReadDir(root)
if os.IsNotExist(err) {
return items, nil
}
if err != nil {
return nil, err
}
var scan func(string, string) error
scan = func(path, organization string) error {
children, err := os.ReadDir(path)
if err != nil {
return err
}
for _, child := range children {
if child.IsDir() || child.Type()&os.ModeSymlink != 0 || strings.HasPrefix(child.Name(), ".") {
continue
}
name := child.Name()
if name == "config.json" || name == "params.json" {
items = append(items, dto.LocalModelOption{Name: filepath.Base(path), Organization: organization, Path: path})
return nil
}
}
if organization == "" {
for _, child := range children {
if child.IsDir() && !strings.HasPrefix(child.Name(), ".") {
if err := scan(filepath.Join(path, child.Name()), filepath.Base(path)); err != nil {
return err
}
}
}
}
return nil
}
for _, entry := range entries {
if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") {
if err := scan(filepath.Join(root, entry.Name()), ""); err != nil {
return nil, err
}
}
}
sort.Slice(items, func(i, j int) bool {
left, right := strings.ToLower(items[i].Name), strings.ToLower(items[j].Name)
if left == right {
return items[i].Path < items[j].Path
}
return left < right
})
return items, nil
}
+17 -1
View File
@@ -45,6 +45,10 @@ var (
// nginxHTTPBlockStartRe locates the http block opening, the fallback
// insertion point, and captures its indentation.
nginxHTTPBlockStartRe = regexp.MustCompile(`(?m)^([ \t]*)http[ \t]*\{[ \t]*\r?$`)
// errNginxHTTPIncludeNoInsertionPoint tells a config the panel cannot edit
// safely apart from an I/O failure while editing it.
errNginxHTTPIncludeNoInsertionPoint = errors.New("no insertion point for the http.d include in nginx.conf")
)
// nginxHTTPIncludePresent reports whether nginx.conf already loads http.d.
@@ -96,7 +100,19 @@ func insertNginxHTTPInclude(content string) (string, error) {
indent := content[m[2]:m[3]] + " "
return content[:m[1]] + eol + indent + nginxHTTPIncludeDirective + content[m[1]:], nil
}
return "", errors.New("no insertion point for the http.d include in nginx.conf")
return "", errNginxHTTPIncludeNoInsertionPoint
}
// nginxHTTPIncludeInsertable reports whether nginx.conf already loads http.d
// or the include can be added automatically. Nothing is written: the
// insertion is computed in memory and discarded.
func nginxHTTPIncludeInsertable(install model.AppInstall) bool {
content, err := os.ReadFile(nginxMainConfigPath(install))
if err != nil {
return false
}
_, err = insertNginxHTTPInclude(string(content))
return err == nil
}
// ensureNginxHTTPIncludeActive makes nginx.conf load http.d, inserting the
+8 -1
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"strings"
"testing"
)
@@ -83,9 +84,15 @@ func TestInsertNginxHTTPIncludeFallsBackToHTTPBlock(t *testing.T) {
}
func TestInsertNginxHTTPIncludeRejectsConfigWithoutHTTPBlock(t *testing.T) {
if _, err := insertNginxHTTPInclude("events {}\n"); err == nil {
_, err := insertNginxHTTPInclude("events {}\n")
if err == nil {
t.Fatal("a config without an http block must be rejected so callers can degrade")
}
// The save path maps this error to an actionable message and must be able
// to tell it apart from an I/O failure.
if !errors.Is(err, errNginxHTTPIncludeNoInsertionPoint) {
t.Fatalf("unexpected error: %v", err)
}
}
func TestInsertNginxHTTPIncludeIgnoresCommentedIncludes(t *testing.T) {
+36 -24
View File
@@ -224,12 +224,9 @@ func getNginxBrotliParams() (*response.NginxBrotliRes, error) {
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
current = readNginxHTTPDirectives(path.Join(nginxHTTPConfigDir(install), fileName))
}
res := &response.NginxBrotliRes{
ManagedExternally: managedExternally,
// Without the include, values the panel would write would never reach
// nginx, so they are reported as unavailable rather than shown as if
// they were in effect.
ManagedUnavailable: !managedExternally && !nginxHTTPIncludePresent(install),
res := &response.NginxBrotliRes{ManagedExternally: managedExternally}
if !managedExternally {
res.ManagedPending, res.ManagedUnavailable = nginxBrotliManagedState(install)
}
for _, directive := range mergeNginxRuntimeDirectives(nginxModuleRuntimeDefaults[nginxBrotliModuleName], current) {
res.Params = append(res.Params, response.NginxParam{Name: directive.Name, Params: directive.Params})
@@ -237,6 +234,28 @@ func getNginxBrotliParams() (*response.NginxBrotliRes, error) {
return res, nil
}
// nginxBrotliManagedState reports whether the managed brotli file is missing
// from what nginx loads, and if so whether saving from the settings page can
// fix that. Either way the reported values are not in effect, so they must
// not be presented as if they were.
//
// A missing include is the common case for installs that enabled brotli
// before the panel managed its directives, and the save path inserts it. Only
// a config without a safe insertion point is reported as unavailable.
func nginxBrotliManagedState(install model.AppInstall) (pending, unavailable bool) {
if !nginxHTTPIncludePresent(install) {
if nginxHTTPIncludeInsertable(install) {
return true, false
}
return false, true
}
fileName := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
if _, err := os.Stat(path.Join(nginxHTTPConfigDir(install), fileName)); os.IsNotExist(err) {
return true, false
}
return false, false
}
// readNginxUserBrotliDirectives collects the brotli directives the user wrote
// in any of the files nginx loads them from.
func readNginxUserBrotliDirectives(install model.AppInstall) map[string][]string {
@@ -313,26 +332,19 @@ func updateNginxBrotliParams(params []dto.NginxParam) error {
return updateUserNginxBrotliParams(install, values)
}
// A managed write needs the include. Installations missing it are
// upgraded in place here; when nginx.conf cannot be edited safely the
// write is refused with an actionable error instead of writing values
// nginx would never load.
if !nginxHTTPIncludePresent(install) {
configPath := nginxMainConfigPath(install)
content, readErr := os.ReadFile(configPath)
if readErr != nil {
return readErr
}
updated, insErr := insertNginxHTTPInclude(string(content))
if insErr != nil {
// upgraded in place here, through the same path that enabling the
// module takes; when nginx.conf cannot be edited safely the write is
// refused with an actionable error instead of writing values nginx
// would never load.
_, confSnapshot, includeErr := ensureNginxHTTPIncludeActive(install)
if includeErr != nil {
if errors.Is(includeErr, errNginxHTTPIncludeNoInsertionPoint) {
return buserr.New("ErrBrotliUnsupported")
}
if err = writeNginxFileAtomic(configPath, []byte(updated)); err != nil {
return err
}
if err = os.MkdirAll(nginxHTTPConfigDir(install), constant.DirPerm); err != nil {
return err
}
if err = nginxCheckAndReload(string(content), configPath, install.ContainerName); err != nil {
return includeErr
}
if confSnapshot != nil {
if err = nginxCheckAndReload(string(confSnapshot), nginxMainConfigPath(install), install.ContainerName); err != nil {
return err
}
}
@@ -229,3 +229,85 @@ func TestValidateNginxBrotliValues(t *testing.T) {
}
}
}
// newBrotliStateInstall lays out an OpenResty install whose nginx.conf holds
// conf, or that has no nginx.conf at all when conf is empty.
func newBrotliStateInstall(t *testing.T, conf string) model.AppInstall {
t.Helper()
installRoot := t.TempDir()
confDir := path.Join(installRoot, "openresty", "openresty", "conf")
if err := os.MkdirAll(confDir, 0o755); err != nil {
t.Fatal(err)
}
if conf != "" {
if err := os.WriteFile(path.Join(confDir, "nginx.conf"), []byte(conf), 0o644); err != nil {
t.Fatal(err)
}
}
previous := global.Dir.AppInstallDir
global.Dir.AppInstallDir = installRoot
t.Cleanup(func() { global.Dir.AppInstallDir = previous })
install := model.AppInstall{Name: "openresty"}
install.App.Key = constant.AppOpenresty
return install
}
// Installs that enabled brotli before the panel managed its directives have
// no http.d include. Saving inserts it, so the settings page must report the
// values as pending rather than as something the panel can never apply.
func TestNginxBrotliManagedState(t *testing.T) {
withInclude, err := insertNginxHTTPInclude(plainNginxConf)
if err != nil {
t.Fatal(err)
}
managedFile := nginxHTTPConfigFileName(nginxModuleRuntimeOrder(nginxBrotliModuleName), nginxBrotliModuleName)
cases := []struct {
name string
conf string
managedFile bool
wantPending bool
wantUnavailable bool
}{
{"include missing but insertable", plainNginxConf, false, true, false},
{"no insertion point", "events {}\n", false, false, true},
{"nginx.conf unreadable", "", false, false, true},
{"include present, managed file missing", withInclude, false, true, false},
{"include present and managed file written", withInclude, true, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
install := newBrotliStateInstall(t, tc.conf)
if tc.managedFile {
configDir := nginxHTTPConfigDir(install)
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path.Join(configDir, managedFile), []byte("brotli on;\n"), 0o644); err != nil {
t.Fatal(err)
}
}
pending, unavailable := nginxBrotliManagedState(install)
if pending != tc.wantPending || unavailable != tc.wantUnavailable {
t.Fatalf("expected pending=%v unavailable=%v, got pending=%v unavailable=%v",
tc.wantPending, tc.wantUnavailable, pending, unavailable)
}
})
}
}
// The state is computed on every visit to the settings page; only an explicit
// save may change the configuration.
func TestNginxBrotliManagedStateDoesNotWrite(t *testing.T) {
install := newBrotliStateInstall(t, plainNginxConf)
nginxBrotliManagedState(install)
content, err := os.ReadFile(nginxMainConfigPath(install))
if err != nil {
t.Fatal(err)
}
if string(content) != plainNginxConf {
t.Fatal("checking the state must not edit nginx.conf")
}
if _, err := os.Stat(nginxHTTPConfigDir(install)); !os.IsNotExist(err) {
t.Fatal("checking the state must not create the http.d directory")
}
}
+1
View File
@@ -121,6 +121,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddModelQuickJump,
migrations.MigrateModelDownloader,
migrations.MigrateVMDatabase,
migrations.AddFeaturedModelWeightProvider,
}
}
@@ -21,6 +21,21 @@ var MigrateModelDownloader = &gormigrate.Migration{
},
}
var AddFeaturedModelWeightProvider = &gormigrate.Migration{
ID: "20261103-add-featured-model-weight-provider",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.FeaturedModel{}); err != nil {
return err
}
if err := tx.Model(&model.FeaturedModel{}).
Where("(weight_provider IS NULL OR weight_provider = '') AND instr(repo_id, '/') > 0").
Update("weight_provider", gorm.Expr("substr(repo_id, 1, instr(repo_id, '/') - 1)")).Error; err != nil {
return err
}
return tx.Model(&model.Setting{}).Where("key = ?", "FeaturedModelsVersion").Update("value", "").Error
},
}
func migrateModelDownloader(db *gorm.DB, legacyPath string) error {
var legacy *gorm.DB
if _, err := os.Stat(legacyPath); err == nil {
+1
View File
@@ -27,6 +27,7 @@ func (m *ModelDownloaderRouter) InitRouter(Router *gin.RouterGroup) {
router.POST("/tasks/retry", baseApi.RetryModelDownloadTask)
router.POST("/tasks/remove", baseApi.RemoveModelDownloadTask)
router.POST("/local/search", baseApi.PageLocalDownloadedModels)
router.GET("/local/options", baseApi.ListLocalModelOptions)
router.POST("/local/delete", baseApi.DeleteLocalDownloadedModel)
}
}
+4 -2
View File
@@ -96,9 +96,10 @@ func LoadMenus() string {
item := []dto.ShowMenu{
{ID: "1", Disabled: true, Title: "menu.home", IsShow: true, Label: "Home-Menu", Path: "/", Sort: 100},
{ID: "2", Disabled: true, Title: "menu.apps", IsShow: true, Label: "App-Menu", Path: "/apps/all", Sort: 200},
{ID: "3", Disabled: false, Title: "menu.aiTools", IsShow: true, Label: "AI-Menu", Path: "/ai/model/account", Sort: 300,
{ID: "3", Disabled: false, Title: "menu.aiTools", IsShow: true, Label: "AI-Menu", Path: "/ai/model/files", Sort: 300,
Children: []dto.ShowMenu{
{ID: "41", Disabled: false, Title: "aiTools.model.model", IsShow: true, Label: "AIModel", Path: "/ai/model/account", Sort: 50},
{ID: "48", Disabled: false, Title: "aiTools.model.ollamaManagement", IsShow: true, Label: "OllamaModels", Path: "/ai/model/local/ollama", Sort: 25},
{ID: "41", Disabled: false, Title: "aiTools.model.vllmManagement", IsShow: true, Label: "AIModel", Path: "/ai/model/files", Sort: 50},
{ID: "44", Disabled: false, Title: "aiTools.agents.agent", IsShow: true, Label: "Agents", Path: "/ai/agents/agent", Sort: 100},
{ID: "42", Disabled: false, Title: "menu.mcp", IsShow: true, Label: "MCPServer", Path: "/ai/mcp", Sort: 200},
{ID: "43", Disabled: false, Title: "aiTools.gpu.gpu", IsShow: true, Label: "GPU", Path: "/ai/gpu", Sort: 300},
@@ -204,6 +205,7 @@ func MenuSort() []dto.MenuLabelSort {
{Label: "Home-Menu", Sort: 100},
{Label: "App-Menu", Sort: 200},
{Label: "AI-Menu", Sort: 300},
{Label: "OllamaModels", Sort: 25},
{Label: "AIModel", Sort: 50},
{Label: "Agents", Sort: 100},
{Label: "AIProxyManagement", Sort: 150},
+1
View File
@@ -74,5 +74,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
migrations.MoveVirtualMachineMenuToTopLevel,
migrations.UpdateAiModelManagementMenus,
}
}
+40
View File
@@ -1406,6 +1406,46 @@ var UpdateAiMenuOrderV2 = &gormigrate.Migration{
},
}
var UpdateAiModelManagementMenus = &gormigrate.Migration{
ID: "20261103-update-ai-model-management-menus",
Migrate: func(tx *gorm.DB) error {
return helper.UpdateHideMenu(tx, func(menus []dto.ShowMenu) []dto.ShowMenu {
for i := range menus {
if menus[i].Label != "AI-Menu" {
continue
}
menus[i].Path = "/ai/model/files"
children := menus[i].Children
showOllama := true
hasOllama := false
insertIndex := 0
for j := range children {
switch children[j].Label {
case "AIModel":
children[j].Title = "aiTools.model.vllmManagement"
children[j].Path = "/ai/model/files"
showOllama = children[j].IsShow
insertIndex = j
case "OllamaModels":
hasOllama = true
}
}
if !hasOllama {
children = append(children, dto.ShowMenu{})
copy(children[insertIndex+1:], children[insertIndex:])
children[insertIndex] = dto.ShowMenu{
ID: "48", Title: "aiTools.model.ollamaManagement", IsShow: showOllama,
Label: "OllamaModels", Path: "/ai/model/local/ollama", Sort: 25,
}
}
menus[i].Children = children
break
}
return menus
})
},
}
var UpdateFirewallMenuPath = &gormigrate.Migration{
ID: "20260819-update-firewall-menu-path",
Migrate: func(tx *gorm.DB) error {
@@ -3,17 +3,20 @@ import { ReqPage, ResPage } from '@/api/interface';
export namespace ModelDownloader {
export interface FeaturedModelSearch extends ReqPage {
family: string;
weightProvider: string;
info: string;
}
export interface FeaturedModelPage extends ResPage<FeaturedModel> {
families: string[];
weightProviders: string[];
}
export interface FeaturedModel {
description: Record<string, string> | null;
family: string;
repoID: string;
weightProvider: string;
sources: Source[];
sourceModelIds?: Partial<Record<Source, string>> | null;
architecture: 'MoE' | 'Dense';
@@ -110,6 +113,12 @@ export namespace ModelDownloader {
canRemoveRecord: boolean;
}
export interface LocalModelOption {
name: string;
organization: string;
path: string;
}
export interface LocalModelSearch extends ReqPage {
downloadType?: DownloadType | '';
info: string;
+1
View File
@@ -10,6 +10,7 @@ export namespace Nginx {
export interface NginxBrotliRes {
params: NginxParam[];
managedExternally: boolean;
managedPending: boolean;
managedUnavailable: boolean;
}
@@ -59,6 +59,10 @@ export const pageLocalDownloadedModels = (req: ModelDownloader.LocalModelSearch)
return http.post<ResPage<ModelDownloader.LocalModelItem>>('/ai/model/downloader/local/search', req);
};
export const listLocalModelOptions = () => {
return http.get<ModelDownloader.LocalModelOption[]>('/ai/model/downloader/local/options');
};
export const deleteLocalDownloadedModel = (req: ModelDownloader.LocalModelDelete) => {
return http.post('/ai/model/downloader/local/delete', req);
};
+20 -4
View File
@@ -1064,6 +1064,7 @@ const message = {
backboneParams: 'MoE backbone parameters',
activeParams: 'Active parameters',
engram: 'Engram',
weightProvider: 'Weight provider',
weightFootprint: 'Weight size',
undisclosed: 'Undisclosed',
featuredModels: 'Featured models',
@@ -1076,10 +1077,24 @@ const message = {
instanceDeployment: 'Deploy by instance',
engines: 'Local inference engines',
modelManagement: 'Model management',
ollamaManagement: 'Ollama management',
vllmManagement: 'vLLM management',
serviceDetails: 'Service details',
deployments: 'Local deployments',
files: 'Model hub',
deployments: 'Model deployment',
files: 'Model preparation',
connectExternal: 'Connect external service',
recipes: 'vLLM Recipes',
createRecipe: 'Create Recipe',
recipeHelper:
'Recipe = model + hardware + launch arguments. Import from {link} or create from a downloaded model.',
recipeFormHelper: 'Paste a command from {link}, or fill in the fields manually.',
recipeArguments: 'Launch arguments',
recipeImportCommand: 'Import command',
recipeImportPlaceholder: 'Paste Docker, pip/uv or vllm serve commands to fill in the Recipe automatically',
recipeVariablesNotSupported:
'Variable references are not supported in commands. Use explicit values; configure environment variables in the environment field.',
recipeImportError:
'Unable to convert. Include a Docker or vllm serve launch command with a model; paste pip/uv installation commands together with the launch command.',
deploymentTemplates: 'vLLM startup command templates',
engineProvider: 'Engine / Provider',
endpoint: 'Endpoint',
@@ -1106,8 +1121,6 @@ const message = {
vllmDeployProfile: 'Deployment model',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'The startup command must use port {0}; otherwise, the service will be inaccessible.',
ascendVisibleDevices: 'Ascend visible devices (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Sync to model service',
modelAccountAddressHelper:
@@ -1115,6 +1128,8 @@ const message = {
containerAddress: 'Container address',
customAddress: 'Custom address',
downloader: 'Downloads',
modelDirectoryPlaceholder: 'Search models or enter a full path',
selectModelDirectory: 'Select directory',
modelDir: 'Model directory',
modelSource: 'Model source',
repoID: 'Repository ID',
@@ -4591,6 +4606,7 @@ const message = {
brotliCompLevelHelper: 'Brotli compression rate, 0 to 11',
brotliManagedExternallyHelper:
'Brotli is configured manually in nginx.conf; the panel shows the values in effect and will not overwrite them.',
brotliManagedPendingHelper: 'Brotli settings are not in effect yet; they take effect after you save.',
brotliManagedUnavailableHelper:
'The panel could not add the managed brotli configuration to nginx.conf automatically; the values below will not take effect.',
brotliMinLengthHelper: 'Minimum response size to brotli-compress',
+21 -4
View File
@@ -1066,6 +1066,7 @@ const message = {
backboneParams: 'Parámetros base de MoE',
activeParams: 'Parámetros activos',
engram: 'Engram',
weightProvider: 'Proveedor de pesos',
weightFootprint: 'Tamaño de los pesos',
undisclosed: 'No divulgado',
featuredModels: 'Modelos destacados',
@@ -1078,10 +1079,25 @@ const message = {
instanceDeployment: 'Despliegue por instancia',
engines: 'Motores de inferencia locales',
modelManagement: 'Gestión de modelos',
ollamaManagement: 'Gestión de Ollama',
vllmManagement: 'Gestión de vLLM',
serviceDetails: 'Detalles del servicio',
deployments: 'Despliegues locales',
files: 'Catálogo de modelos',
deployments: 'Despliegue de modelos',
files: 'Preparación de modelos',
connectExternal: 'Conectar servicio externo',
recipes: 'vLLM Recipes',
createRecipe: 'Crear Recipe',
recipeHelper:
'Recipe = modelo + hardware + argumentos de inicio. Importe desde {link} o cree una a partir de un modelo descargado.',
recipeFormHelper: 'Pegue un comando de {link} o complete los campos manualmente.',
recipeArguments: 'Argumentos de inicio',
recipeImportCommand: 'Importar comando',
recipeImportPlaceholder:
'Pegue comandos Docker, pip/uv o vllm serve para completar la Recipe automáticamente',
recipeVariablesNotSupported:
'No se admiten referencias a variables en los comandos. Use valores concretos; configure las variables de entorno en su campo correspondiente.',
recipeImportError:
'No se puede convertir. Incluya un comando de inicio Docker o vllm serve con un modelo; pegue los comandos de instalación pip/uv junto con el comando de inicio.',
deploymentTemplates: 'Plantillas de comandos de inicio de vLLM',
engineProvider: 'Motor / Proveedor',
endpoint: 'Punto de conexión',
@@ -1109,8 +1125,6 @@ const message = {
vllmDeployProfile: 'Modelo de despliegue',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'El comando de inicio debe usar el puerto {0}; de lo contrario, no se podrá acceder al servicio.',
ascendVisibleDevices: 'Dispositivos Ascend visibles (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Sincronizar con servicio de modelo',
modelAccountAddressHelper:
@@ -1118,6 +1132,8 @@ const message = {
containerAddress: 'Dirección del contenedor',
customAddress: 'Dirección personalizada',
downloader: 'Gestión de descargas',
modelDirectoryPlaceholder: 'Buscar modelos o introducir una ruta completa',
selectModelDirectory: 'Seleccionar directorio',
modelDir: 'Directorio del modelo',
modelSource: 'Fuente del modelo',
repoID: 'ID del repositorio',
@@ -4650,6 +4666,7 @@ const message = {
brotliCompLevelHelper: 'Tasa de compresión brotli, de 0 a 11',
brotliManagedExternallyHelper:
'Brotli está configurado manualmente en nginx.conf; el panel muestra los valores en vigor y no los sobrescribe.',
brotliManagedPendingHelper: 'La configuración de brotli aún no está en vigor; se aplicará al guardar.',
brotliManagedUnavailableHelper:
'El panel no pudo añadir automáticamente la configuración brotli gestionada a nginx.conf; los valores siguientes no tendrán efecto.',
brotliMinLengthHelper: 'Tamaño mínimo de respuesta para comprimir con brotli',
+21 -4
View File
@@ -1047,6 +1047,7 @@ const message = {
backboneParams: 'پارامترهای پایه MoE',
activeParams: 'پارامترهای فعال',
engram: 'Engram',
weightProvider: 'ارائه‌دهنده وزن‌ها',
weightFootprint: 'حجم وزن‌ها',
undisclosed: 'اعلام نشده',
featuredModels: 'مدل‌های برگزیده',
@@ -1059,10 +1060,25 @@ const message = {
instanceDeployment: 'استقرار بر اساس نمونه',
engines: 'موتورهای استنتاج محلی',
modelManagement: 'مدیریت مدل‌ها',
ollamaManagement: 'مدیریت Ollama',
vllmManagement: 'مدیریت vLLM',
serviceDetails: 'جزئیات سرویس',
deployments: 'استقرارهای محلی',
files: 'مرکز مدل‌ها',
deployments: 'استقرار مدل',
files: 'آماده‌سازی مدل',
connectExternal: 'اتصال به سرویس خارجی',
recipes: 'vLLM Recipes',
createRecipe: 'ایجاد Recipe',
recipeHelper:
'Recipe = مدل + سخت‌افزار + آرگومان‌های راه‌اندازی. از {link} وارد کنید یا از مدل دانلودشده بسازید.',
recipeFormHelper: 'فرمان را از {link} جای‌گذاری کنید یا فیلدها را دستی پر کنید.',
recipeArguments: 'آرگومان‌های راه‌اندازی',
recipeImportCommand: 'وارد کردن فرمان',
recipeImportPlaceholder:
'فرمان‌های Docker، pip/uv یا vllm serve را برای تکمیل خودکار Recipe جای‌گذاری کنید',
recipeVariablesNotSupported:
'ارجاع به متغیرها در دستور پشتیبانی نمی‌شود. مقادیر مشخص وارد کنید؛ متغیرهای محیطی را در بخش مربوط به آن‌ها تنظیم کنید.',
recipeImportError:
'تبدیل ممکن نیست. فرمان راه‌اندازی Docker یا vllm serve همراه با مدل را وارد کنید؛ فرمان‌های نصب pip/uv را همراه با فرمان راه‌اندازی جای‌گذاری کنید.',
deploymentTemplates: 'الگوهای فرمان راه‌اندازی vLLM',
engineProvider: 'موتور / ارائه‌دهنده',
endpoint: 'نقطه پایانی',
@@ -1089,8 +1105,6 @@ const message = {
vllmDeployProfile: 'مدل استقرار',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'فرمان راه‌اندازی باید از پورت {0} استفاده کند؛ در غیر این صورت سرویس قابل دسترسی نخواهد بود.',
ascendVisibleDevices: 'دستگاه‌های قابل مشاهده Ascend (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'همگام‌سازی با سرویس مدل',
modelAccountAddressHelper:
@@ -1098,6 +1112,8 @@ const message = {
containerAddress: 'آدرس کانتینر',
customAddress: 'آدرس سفارشی',
downloader: 'مدیریت دانلودها',
modelDirectoryPlaceholder: 'جستجوی مدل یا وارد کردن مسیر کامل',
selectModelDirectory: 'انتخاب پوشه',
modelDir: 'دایرکتوری مدل',
modelSource: 'منبع مدل',
repoID: 'شناسه مخزن',
@@ -4553,6 +4569,7 @@ const message = {
brotliCompLevelHelper: 'نرخ فشرده‌سازی brotli، از 0 تا 11',
brotliManagedExternallyHelper:
'brotli به‌صورت دستی در nginx.conf پیکربندی شده است؛ پنل فقط مقادیر در حال اجرا را نشان می‌دهد و آن‌ها را بازنویسی نمی‌کند.',
brotliManagedPendingHelper: 'تنظیمات brotli هنوز اعمال نشده است؛ پس از ذخیره اعمال می‌شود.',
brotliManagedUnavailableHelper:
'پنل نتوانست پیکربندی مدیریت‌شده brotli را به‌صورت خودکار به nginx.conf اضافه کند؛ مقادیر زیر اعمال نخواهند شد.',
brotliMinLengthHelper: 'حداقل اندازه پاسخ برای فشرده‌سازی با brotli',
+20 -4
View File
@@ -1051,6 +1051,7 @@ const message = {
backboneParams: 'MoE バックボーンパラメータ数',
activeParams: 'アクティブパラメータ数',
engram: 'Engram',
weightProvider: '重みの提供元',
weightFootprint: '重みのサイズ',
undisclosed: '非公開',
featuredModels: '注目のモデル',
@@ -1063,10 +1064,24 @@ const message = {
instanceDeployment: 'インスタンス単位でデプロイ',
engines: 'ローカル推論エンジン',
modelManagement: 'モデル管理',
ollamaManagement: 'Ollama 管理',
vllmManagement: 'vLLM 管理',
serviceDetails: 'サービス詳細',
deployments: 'ローカルデプロイ',
files: 'モデル広場',
deployments: 'モデルデプロイ',
files: 'モデル準備',
connectExternal: '外部サービスに接続',
recipes: 'vLLM Recipes',
createRecipe: 'Recipe を作成',
recipeHelper:
'Recipe = モデル + ハードウェア + 起動引数。{link} からインポートするか、ダウンロード済みモデルから作成できます。',
recipeFormHelper: '{link} のコマンドを貼り付けるか、手動で入力してください。',
recipeArguments: '起動引数',
recipeImportCommand: 'コマンドをインポート',
recipeImportPlaceholder: 'Docker、pip/uv、または vllm serve コマンドを貼り付けて Recipe に自動入力',
recipeVariablesNotSupported:
'コマンド内の変数参照はサポートされていません。具体的な値を入力し、環境変数は環境変数欄で設定してください。',
recipeImportError:
'変換できません。モデルを含む Docker または vllm serve の起動コマンドを入力してください。pip/uv のインストールコマンドは起動コマンドと一緒に貼り付けてください。',
deploymentTemplates: 'vLLM 起動コマンドテンプレート',
engineProvider: 'エンジン / プロバイダー',
endpoint: 'エンドポイント',
@@ -1094,8 +1109,6 @@ const message = {
vllmDeployProfile: 'デプロイモデル',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'起動コマンドではポート {0} を使用する必要があります。使用しない場合、サービスにアクセスできません。',
ascendVisibleDevices: 'Ascend 可視デバイス(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'モデルサービスに同期',
modelAccountAddressHelper:
@@ -1103,6 +1116,8 @@ const message = {
containerAddress: 'コンテナアドレス',
customAddress: 'カスタムアドレス',
downloader: 'ダウンロード管理',
modelDirectoryPlaceholder: 'モデルを検索するかフルパスを入力',
selectModelDirectory: 'ディレクトリを選択',
modelDir: 'モデルディレクトリ',
modelSource: 'モデルソース',
repoID: 'リポジトリ ID',
@@ -4574,6 +4589,7 @@ const message = {
brotliCompLevelHelper: 'brotli 圧縮率、0 から 11',
brotliManagedExternallyHelper:
'brotli は nginx.conf で手動設定されています。パネルは現在の有効な値を表示するだけで、上書きしません。',
brotliManagedPendingHelper: 'brotli 設定はまだ有効になっていません。保存すると有効になります。',
brotliManagedUnavailableHelper:
'パネルは管理対象の brotli 設定を nginx.conf に自動で追加できませんでした。以下の設定は有効になりません。',
brotliMinLengthHelper: 'brotli で圧縮する最小レスポンスサイズ',
+20 -3
View File
@@ -1041,6 +1041,7 @@ const message = {
backboneParams: 'MoE 백본 매개변수 수',
activeParams: '활성 매개변수 수',
engram: 'Engram',
weightProvider: '가중치 제공자',
weightFootprint: '가중치 크기',
undisclosed: '비공개',
featuredModels: '추천 모델',
@@ -1053,10 +1054,24 @@ const message = {
instanceDeployment: '인스턴스별 배포',
engines: '로컬 추론 엔진',
modelManagement: '모델 관리',
ollamaManagement: 'Ollama 관리',
vllmManagement: 'vLLM 관리',
serviceDetails: '서비스 상세 정보',
deployments: '로컬 배포',
files: '모델 허브',
deployments: '모델 배포',
files: '모델 준비',
connectExternal: '외부 서비스 연결',
recipes: 'vLLM Recipes',
createRecipe: 'Recipe 만들기',
recipeHelper:
'Recipe = 모델 + 하드웨어 + 시작 인수. {link}에서 가져오거나 다운로드한 모델로 만들 수 있습니다.',
recipeFormHelper: '{link}의 명령을 붙여넣거나 직접 입력하세요.',
recipeArguments: '시작 인수',
recipeImportCommand: '명령 가져오기',
recipeImportPlaceholder: 'Docker, pip/uv 또는 vllm serve 명령을 붙여넣어 Recipe 자동 입력',
recipeVariablesNotSupported:
'명령에서 변수 참조는 지원되지 않습니다. 구체적인 값을 입력하고 환경 변수는 환경 변수 필드에서 설정하세요.',
recipeImportError:
'변환할 수 없습니다. 모델이 포함된 Docker 또는 vllm serve 시작 명령을 입력하세요. pip/uv 설치 명령은 시작 명령과 함께 붙여넣으세요.',
deploymentTemplates: 'vLLM 시작 명령 템플릿',
engineProvider: '엔진 / 공급자',
endpoint: '엔드포인트',
@@ -1083,7 +1098,6 @@ const message = {
vllmDeployProfile: '배포 모델',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '시작 명령은 {0} 포트를 사용해야 하며, 그렇지 않으면 서비스에 접근할 수 없습니다.',
ascendVisibleDevices: 'Ascend 표시 장치 (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '모델 서비스에 동기화',
modelAccountAddressHelper:
@@ -1091,6 +1105,8 @@ const message = {
containerAddress: '컨테이너 주소',
customAddress: '사용자 지정 주소',
downloader: '다운로드 관리',
modelDirectoryPlaceholder: '모델 검색 또는 전체 경로 입력',
selectModelDirectory: '디렉터리 선택',
modelDir: '모델 디렉토리',
modelSource: '모델 소스',
repoID: '저장소 ID',
@@ -4492,6 +4508,7 @@ const message = {
brotliCompLevelHelper: 'brotli 압축률, 0에서 11까지',
brotliManagedExternallyHelper:
'brotli가 nginx.conf에 수동으로 구성되어 있습니다. 패널은 적용 중인 값만 표시하며 덮어쓰지 않습니다.',
brotliManagedPendingHelper: 'brotli 설정이 아직 적용되지 않았습니다. 저장하면 적용됩니다.',
brotliManagedUnavailableHelper:
'패널이 관리되는 brotli 구성을 nginx.conf에 자동으로 추가할 수 없습니다. 아래 설정은 적용되지 않습니다.',
brotliMinLengthHelper: 'brotli로 압축할 최소 응답 크기',
+20 -3
View File
@@ -1038,6 +1038,7 @@ const message = {
backboneParams: 'ພາຣາມິເຕີຫຼັກຂອງ MoE',
activeParams: 'ພາຣາມິເຕີທີ່ໃຊ້ງານ',
engram: 'Engram',
weightProvider: 'ຜູ້ໃຫ້ບໍລິການນ້ຳໜັກໂມເດວ',
weightFootprint: 'ຂະໜາດນ້ຳໜັກ',
undisclosed: 'ບໍ່ເປີດເຜີຍ',
featuredModels: 'ໂມເດວແນະນຳ',
@@ -1050,10 +1051,24 @@ const message = {
instanceDeployment: 'ນຳໃຊ້ຕາມອິນສະແຕນ',
engines: 'ເຄື່ອງຈັກອະນຸມານພາຍໃນ',
modelManagement: 'ຈັດການໂມເດວ',
ollamaManagement: 'ການຈັດການ Ollama',
vllmManagement: 'ການຈັດການ vLLM',
serviceDetails: 'ລາຍລະອຽດບໍລິການ',
deployments: 'ການນຳໃຊ້ພາຍໃນເຄື່ອງ',
files: 'ສູນລວມໂມເດວ',
deployments: 'ການນຳໃຊ້ໂມເດວ',
files: 'ການກະກຽມໂມເດວ',
connectExternal: 'ເຊື່ອມຕໍ່ບໍລິການພາຍນອກ',
recipes: 'vLLM Recipes',
createRecipe: 'ສ້າງ Recipe',
recipeHelper:
'Recipe = ໂມເດວ + ຮາດແວ + ພາລາມິເຕີເລີ່ມຕົ້ນ. ນຳເຂົ້າຈາກ {link} ຫຼືສ້າງຈາກໂມເດວທີ່ດາວໂຫຼດແລ້ວ.',
recipeFormHelper: 'ວາງຄຳສັ່ງຈາກ {link} ຫຼືປ້ອນຂໍ້ມູນເອງ.',
recipeArguments: 'ພາລາມິເຕີເລີ່ມຕົ້ນ',
recipeImportCommand: 'ນຳເຂົ້າຄຳສັ່ງ',
recipeImportPlaceholder: 'ວາງຄຳສັ່ງ Docker, pip/uv ຫຼື vllm serve ເພື່ອຕື່ມ Recipe ອັດຕະໂນມັດ',
recipeVariablesNotSupported:
'ຄຳສັ່ງບໍ່ຮອງຮັບການອ້າງອີງຕົວແປ. ກະລຸນາປ້ອນຄ່າໂດຍກົງ ແລະຕັ້ງຄ່າຕົວແປສະພາບແວດລ້ອມໃນຊ່ອງທີ່ກ່ຽວຂ້ອງ.',
recipeImportError:
'ບໍ່ສາມາດແປງໄດ້. ກະລຸນາໃສ່ຄຳສັ່ງເລີ່ມຕົ້ນ Docker ຫຼື vllm serve ທີ່ມີໂມເດວ; ວາງຄຳສັ່ງຕິດຕັ້ງ pip/uv ພ້ອມກັບຄຳສັ່ງເລີ່ມຕົ້ນ.',
deploymentTemplates: 'ແມ່ແບບຄຳສັ່ງເລີ່ມຕົ້ນ vLLM',
engineProvider: 'ເຄື່ອງຈັກ / ຜູ້ໃຫ້ບໍລິການ',
endpoint: 'ຈຸດເຊື່ອມຕໍ່',
@@ -1080,7 +1095,6 @@ const message = {
vllmDeployProfile: 'ໂມເດວສຳລັບຕິດຕັ້ງ',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: 'ຄຳສັ່ງເລີ່ມຕົ້ນຕ້ອງໃຊ້ພອດ {0}; ບໍ່ດັ່ງນັ້ນຈະບໍ່ສາມາດເຂົ້າເຖິງບໍລິການໄດ້.',
ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້ (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'ຊິ້ງຄ໌ໄປຍັງບໍລິການໂມເດວ',
modelAccountAddressHelper:
@@ -1088,6 +1102,8 @@ const message = {
containerAddress: 'ທີ່ຢູ່ຄອນເທນເນີ',
customAddress: 'ທີ່ຢູ່ກຳນົດເອງ',
downloader: 'ຈັດການການດາວໂຫຼດ',
modelDirectoryPlaceholder: 'ຄົ້ນຫາໂມເດວ ຫຼື ປ້ອນເສັ້ນທາງເຕັມ',
selectModelDirectory: 'ເລືອກໄດເຣັກທໍຣີ',
modelDir: 'ໄດເຣັກທໍຣີໂມເດວ',
modelSource: 'ແຫຼ່ງທີ່ມາໂມເດວ',
repoID: 'ID ຄັງເກັບ',
@@ -4465,6 +4481,7 @@ const message = {
brotliCompLevelHelper: 'ອັດຕາການບີບອັດ brotli, 0 ຫາ 11',
brotliManagedExternallyHelper:
'brotli ຖືກຕັ້ງຄ່າດ້ວຍມືໃນ nginx.conf; ແຜງຄວບຄຸມຈະສະແດງຄ່າທີ່ກຳລັງໃຊ້ຢູ່ ແລະ ຈະບໍ່ຂຽບທັບ.',
brotliManagedPendingHelper: 'ການຕັ້ງຄ່າ brotli ຍັງບໍ່ມີຜົນ; ຈະມີຜົນຫຼັງຈາກບັນທຶກ.',
brotliManagedUnavailableHelper:
'ແຜງຄວບຄຸມບໍ່ສາມາດເພີ່ມການຕັ້ງຄ່າ brotli ທີ່ຈັດການໄປຍັງ nginx.conf ໂດຍອັດຕະໂນມັດໄດ້; ຄ່າຂ້າງລຸ່ມນີ້ຈະບໍ່ມີຜົນ.',
brotliMinLengthHelper: 'ຂະໜາດຕອບສະໜອງຂັ້ນຕ່ຳທີ່ຈະບີບອັດດ້ວຍ brotli',
+21 -4
View File
@@ -1074,6 +1074,7 @@ const message = {
backboneParams: 'Parameter teras MoE',
activeParams: 'Parameter aktif',
engram: 'Engram',
weightProvider: 'Penyedia pemberat',
weightFootprint: 'Saiz pemberat',
undisclosed: 'Tidak didedahkan',
featuredModels: 'Model pilihan',
@@ -1086,10 +1087,25 @@ const message = {
instanceDeployment: 'Pengerahan mengikut instans',
engines: 'Enjin inferens setempat',
modelManagement: 'Pengurusan model',
ollamaManagement: 'Pengurusan Ollama',
vllmManagement: 'Pengurusan vLLM',
serviceDetails: 'Butiran perkhidmatan',
deployments: 'Pengerahan setempat',
files: 'Hab model',
deployments: 'Pengerahan model',
files: 'Penyediaan model',
connectExternal: 'Sambungkan perkhidmatan luaran',
recipes: 'vLLM Recipes',
createRecipe: 'Cipta Recipe',
recipeHelper:
'Recipe = model + perkakasan + argumen pelancaran. Import daripada {link} atau cipta daripada model yang dimuat turun.',
recipeFormHelper: 'Tampal arahan daripada {link} atau isi medan secara manual.',
recipeArguments: 'Argumen pelancaran',
recipeImportCommand: 'Import arahan',
recipeImportPlaceholder:
'Tampal arahan Docker, pip/uv atau vllm serve untuk mengisi Recipe secara automatik',
recipeVariablesNotSupported:
'Rujukan pemboleh ubah tidak disokong dalam arahan. Masukkan nilai khusus; tetapkan pemboleh ubah persekitaran dalam medan yang berkenaan.',
recipeImportError:
'Tidak dapat menukar. Sertakan arahan pelancaran Docker atau vllm serve dengan model; tampal arahan pemasangan pip/uv bersama arahan pelancaran.',
deploymentTemplates: 'Templat arahan permulaan vLLM',
engineProvider: 'Enjin / Pembekal',
endpoint: 'Titik akhir',
@@ -1116,8 +1132,6 @@ const message = {
vllmDeployProfile: 'Model penggunaan',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'Perintah permulaan mesti menggunakan port {0}; jika tidak, perkhidmatan tidak dapat diakses.',
ascendVisibleDevices: 'Peranti Ascend boleh dilihat (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Segerakkan ke perkhidmatan model',
modelAccountAddressHelper:
@@ -1125,6 +1139,8 @@ const message = {
containerAddress: 'Alamat kontena',
customAddress: 'Alamat tersuai',
downloader: 'Pengurusan muat turun',
modelDirectoryPlaceholder: 'Cari model atau masukkan laluan penuh',
selectModelDirectory: 'Pilih direktori',
modelDir: 'Direktori Model',
modelSource: 'Sumber model',
repoID: 'ID Repositori',
@@ -4659,6 +4675,7 @@ const message = {
brotliCompLevelHelper: 'Kadar pemampatan brotli, 0 hingga 11',
brotliManagedExternallyHelper:
'Brotli dikonfigurasikan secara manual dalam nginx.conf; panel memaparkan nilai yang berkuat kuasa dan tidak akan menimpanya.',
brotliManagedPendingHelper: 'Tetapan brotli belum berkuat kuasa; ia akan berkuat kuasa selepas disimpan.',
brotliManagedUnavailableHelper:
'Panel tidak dapat menambahkan konfigurasi brotli terurus ke nginx.conf secara automatik; nilai di bawah tidak akan berkuat kuasa.',
brotliMinLengthHelper: 'Saiz respons minimum untuk dimampatkan dengan brotli',
+21 -4
View File
@@ -1070,6 +1070,7 @@ const message = {
backboneParams: 'Parâmetros base do MoE',
activeParams: 'Parâmetros ativos',
engram: 'Engram',
weightProvider: 'Provedor de pesos',
weightFootprint: 'Tamanho dos pesos',
undisclosed: 'Não divulgado',
featuredModels: 'Modelos em destaque',
@@ -1082,10 +1083,25 @@ const message = {
instanceDeployment: 'Implantação por instância',
engines: 'Mecanismos de inferência locais',
modelManagement: 'Gerenciamento de modelos',
ollamaManagement: 'Gerenciamento do Ollama',
vllmManagement: 'Gerenciamento do vLLM',
serviceDetails: 'Detalhes do serviço',
deployments: 'Implantações locais',
files: 'Central de modelos',
deployments: 'Implantação de modelos',
files: 'Preparação de modelos',
connectExternal: 'Conectar serviço externo',
recipes: 'vLLM Recipes',
createRecipe: 'Criar Recipe',
recipeHelper:
'Recipe = modelo + hardware + argumentos de inicialização. Importe de {link} ou crie a partir de um modelo baixado.',
recipeFormHelper: 'Cole um comando de {link} ou preencha os campos manualmente.',
recipeArguments: 'Argumentos de inicialização',
recipeImportCommand: 'Importar comando',
recipeImportPlaceholder:
'Cole comandos Docker, pip/uv ou vllm serve para preencher a Recipe automaticamente',
recipeVariablesNotSupported:
'Referências a variáveis não são aceitas nos comandos. Use valores explícitos; configure as variáveis de ambiente no campo correspondente.',
recipeImportError:
'Não foi possível converter. Inclua um comando de inicialização Docker ou vllm serve com um modelo; cole os comandos de instalação pip/uv junto com o comando de inicialização.',
deploymentTemplates: 'Modelos de comandos de inicialização do vLLM',
engineProvider: 'Mecanismo / Provedor',
endpoint: 'Endpoint',
@@ -1112,8 +1128,6 @@ const message = {
vllmDeployProfile: 'Modelo de implantação',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'O comando de inicialização deve usar a porta {0}; caso contrário, o serviço ficará inacessível.',
ascendVisibleDevices: 'Dispositivos Ascend visíveis (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Sincronizar com serviço de modelo',
modelAccountAddressHelper:
@@ -1121,6 +1135,8 @@ const message = {
containerAddress: 'Endereço do contêiner',
customAddress: 'Endereço personalizado',
downloader: 'Gerenciamento de downloads',
modelDirectoryPlaceholder: 'Buscar modelos ou inserir um caminho completo',
selectModelDirectory: 'Selecionar diretório',
modelDir: 'Diretório do modelo',
modelSource: 'Fonte do modelo',
repoID: 'ID do repositório',
@@ -4678,6 +4694,7 @@ const message = {
brotliCompLevelHelper: 'Taxa de compressão brotli, de 0 a 11',
brotliManagedExternallyHelper:
'O brotli está configurado manualmente no nginx.conf; o painel mostra os valores em vigor e não os sobrescreve.',
brotliManagedPendingHelper: 'As configurações brotli ainda não estão em vigor; serão aplicadas ao salvar.',
brotliManagedUnavailableHelper:
'O painel não conseguiu adicionar automaticamente a configuração brotli gerenciada ao nginx.conf; os valores abaixo não terão efeito.',
brotliMinLengthHelper: 'Tamanho mínimo da resposta para compressão brotli',
+21 -3
View File
@@ -1063,6 +1063,7 @@ const message = {
backboneParams: 'Параметры основы MoE',
activeParams: 'Активные параметры',
engram: 'Engram',
weightProvider: 'Поставщик весов',
weightFootprint: 'Размер весов',
undisclosed: 'Не раскрыто',
featuredModels: 'Рекомендуемые модели',
@@ -1075,10 +1076,25 @@ const message = {
instanceDeployment: 'Развертывание по экземплярам',
engines: 'Локальные движки инференса',
modelManagement: 'Управление моделями',
ollamaManagement: 'Управление Ollama',
vllmManagement: 'Управление vLLM',
serviceDetails: 'Сведения о сервисе',
deployments: 'Локальные развертывания',
files: 'Каталог моделей',
deployments: 'Развертывание моделей',
files: 'Подготовка моделей',
connectExternal: 'Подключить внешний сервис',
recipes: 'vLLM Recipes',
createRecipe: 'Создать Recipe',
recipeHelper:
'Recipe = модель + оборудование + параметры запуска. Импортируйте с {link} или создайте из загруженной модели.',
recipeFormHelper: 'Вставьте команду с {link} или заполните поля вручную.',
recipeArguments: 'Параметры запуска',
recipeImportCommand: 'Импорт команды',
recipeImportPlaceholder:
'Вставьте команды Docker, pip/uv или vllm serve для автоматического заполнения Recipe',
recipeVariablesNotSupported:
'Ссылки на переменные в командах не поддерживаются. Укажите конкретные значения; переменные окружения настройте в соответствующем поле.',
recipeImportError:
'Не удалось преобразовать. Добавьте команду запуска Docker или vllm serve с моделью; вставляйте команды установки pip/uv вместе с командой запуска.',
deploymentTemplates: 'Шаблоны команд запуска vLLM',
engineProvider: 'Движок / Поставщик',
endpoint: 'Конечная точка',
@@ -1106,7 +1122,6 @@ const message = {
vllmDeployProfile: 'Модель развертывания',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: 'Команда запуска должна использовать порт {0}, иначе сервис будет недоступен.',
ascendVisibleDevices: 'Видимые устройства Ascend (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Синхронизировать с сервисом модели',
modelAccountAddressHelper:
@@ -1114,6 +1129,8 @@ const message = {
containerAddress: 'Адрес контейнера',
customAddress: 'Пользовательский адрес',
downloader: 'Управление загрузками',
modelDirectoryPlaceholder: 'Найти модель или ввести полный путь',
selectModelDirectory: 'Выбрать каталог',
modelDir: 'Каталог модели',
modelSource: 'Источник модели',
repoID: 'ID репозитория',
@@ -4649,6 +4666,7 @@ const message = {
brotliCompLevelHelper: 'Степень сжатия brotli, от 0 до 11',
brotliManagedExternallyHelper:
'Brotli настроен вручную в nginx.conf; панель показывает действующие значения и не перезаписывает их.',
brotliManagedPendingHelper: 'Настройки brotli ещё не действуют; они вступят в силу после сохранения.',
brotliManagedUnavailableHelper:
'Панель не смогла автоматически добавить управляемую конфигурацию brotli в nginx.conf; значения ниже не вступят в силу.',
brotliMinLengthHelper: 'Минимальный размер ответа для сжатия brotli',
+21 -4
View File
@@ -1072,6 +1072,7 @@ const message = {
backboneParams: 'MoE omurga parametreleri',
activeParams: 'Etkin parametreler',
engram: 'Engram',
weightProvider: 'Ağırlık sağlayıcısı',
weightFootprint: 'Ağırlık boyutu',
undisclosed: 'Açıklanmadı',
featuredModels: 'Öne çıkan modeller',
@@ -1084,10 +1085,25 @@ const message = {
instanceDeployment: 'Örnek bazında dağıtım',
engines: 'Yerel çıkarım motorları',
modelManagement: 'Model yönetimi',
ollamaManagement: 'Ollama yönetimi',
vllmManagement: 'vLLM yönetimi',
serviceDetails: 'Hizmet ayrıntıları',
deployments: 'Yerel dağıtımlar',
files: 'Model merkezi',
deployments: 'Model dağıtımı',
files: 'Model hazırlığı',
connectExternal: 'Harici hizmete bağlan',
recipes: 'vLLM Recipes',
createRecipe: 'Recipe oluştur',
recipeHelper:
'Recipe = model + donanım + başlatma parametreleri. {link} üzerinden içe aktarın veya indirilen bir modelden oluşturun.',
recipeFormHelper: '{link} üzerinden bir komut yapıştırın veya alanları elle doldurun.',
recipeArguments: 'Başlatma parametreleri',
recipeImportCommand: 'Komut içe aktar',
recipeImportPlaceholder:
'Recipe alanlarını otomatik doldurmak için Docker, pip/uv veya vllm serve komutlarını yapıştırın',
recipeVariablesNotSupported:
'Komutlarda değişken başvuruları desteklenmez. Açık değerler girin; ortam değişkenlerini ilgili alanda yapılandırın.',
recipeImportError:
'Dönüştürülemedi. Model içeren bir Docker veya vllm serve başlatma komutu ekleyin; pip/uv kurulum komutlarını başlatma komutuyla birlikte yapıştırın.',
deploymentTemplates: 'vLLM başlatma komutu şablonları',
engineProvider: 'Motor / Sağlayıcı',
endpoint: 'Uç nokta',
@@ -1114,8 +1130,6 @@ const message = {
vllmDeployProfile: 'Dağıtım modeli',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper:
'Başlatma komutu {0} numaralı bağlantı noktasını kullanmalıdır; aksi halde hizmete erişilemez.',
ascendVisibleDevices: 'Görünür Ascend cihazları (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Model hizmetine senkronize et',
modelAccountAddressHelper:
@@ -1123,6 +1137,8 @@ const message = {
containerAddress: 'Konteyner adresi',
customAddress: 'Özel adres',
downloader: 'İndirme yönetimi',
modelDirectoryPlaceholder: 'Model arayın veya tam yol girin',
selectModelDirectory: 'Dizin seç',
modelDir: 'Model dizini',
modelSource: 'Model kaynağı',
repoID: 'Depo ID',
@@ -4664,6 +4680,7 @@ const message = {
brotliCompLevelHelper: 'Brotli sıkıştırma oranı, 0 ile 11 arası',
brotliManagedExternallyHelper:
'Brotli, nginx.conf dosyasında elle yapılandırıldı; panel yalnızca geçerli değerleri gösterir ve üzerine yazmaz.',
brotliManagedPendingHelper: 'Brotli ayarları henüz etkin değil; kaydettikten sonra etkinleşir.',
brotliManagedUnavailableHelper:
'Panel, yönetilen brotli yapılandırmasını nginx.conf dosyasına otomatik olarak ekleyemedi; aşağıdaki değerler etkin olmayacak.',
brotliMinLengthHelper: 'brotli ile sıkıştırılacak minimum yanıt boyutu',
+18 -3
View File
@@ -1002,6 +1002,7 @@ const message = {
backboneParams: 'MoE 骨幹參數',
activeParams: '啟用參數',
engram: 'Engram',
weightProvider: '權重提供商',
weightFootprint: '權重體積',
undisclosed: '未公開',
featuredModels: '精選模型',
@@ -1014,10 +1015,22 @@ const message = {
instanceDeployment: '按執行個體部署',
engines: '本機推理引擎',
modelManagement: '模型管理',
ollamaManagement: 'Ollama 管理',
vllmManagement: 'vLLM 管理',
serviceDetails: '服務詳情',
deployments: '本機部署',
files: '模型廣場',
deployments: '模型部署',
files: '模型準備',
connectExternal: '接入外部服務',
recipes: 'vLLM Recipes',
createRecipe: '建立 Recipe',
recipeHelper: 'Recipe = 模型 + 硬體 + 啟動參數。支援從 {link} 匯入或從已下載模型建立。',
recipeFormHelper: '從 {link} 複製命令匯入,或手動填寫。',
recipeArguments: '啟動參數',
recipeImportCommand: '匯入命令',
recipeImportPlaceholder: '貼上 Docker、pip/uv 或 vllm serve 命令,自動填入 Recipe',
recipeVariablesNotSupported: '命令中不支援變數引用,請填寫具體值;環境變數請在環境變數欄設定。',
recipeImportError:
'無法轉換,請提供包含模型的 Docker 或 vllm serve 啟動命令;pip/uv 安裝命令需要與啟動命令一起貼上。',
deploymentTemplates: 'vLLM 啟動命令範本',
engineProvider: '引擎 / 供應商',
endpoint: '端點',
@@ -1044,7 +1057,6 @@ const message = {
vllmDeployProfile: '部署模型',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '啟動命令必須使用 {0} 連接埠,否則服務將無法存取。',
ascendVisibleDevices: 'Ascend 可見裝置(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型服務',
modelAccountAddressHelper:
@@ -1052,6 +1064,8 @@ const message = {
containerAddress: '容器地址',
customAddress: '自訂地址',
downloader: '下載管理',
modelDirectoryPlaceholder: '搜尋模型或輸入完整路徑',
selectModelDirectory: '選擇目錄',
modelDir: '模型目錄',
modelSource: '模型來源',
repoID: '倉庫 ID',
@@ -4292,6 +4306,7 @@ const message = {
brotliHelper: '開啟 brotli 壓縮,通常比 gzip 體積更小',
brotliCompLevelHelper: 'brotli 壓縮率,取值 0 到 11',
brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手動設定,面板僅顯示目前生效值,不會覆寫。',
brotliManagedPendingHelper: 'brotli 設定尚未生效,點擊儲存後生效。',
brotliManagedUnavailableHelper: '面板無法自動向 nginx.conf 寫入託管的 brotli 設定,以下設定不會生效。',
brotliMinLengthHelper: 'brotli 壓縮的最小回應大小',
brotliSaveFailed: 'brotli 設定儲存失敗,上方的 gzip 設定已套用',
+18 -3
View File
@@ -1015,6 +1015,7 @@ const message = {
backboneParams: 'MoE 骨干参数',
activeParams: '激活参数',
engram: 'Engram',
weightProvider: '权重提供商',
weightFootprint: '权重体积',
undisclosed: '未公开',
featuredModels: '精选模型',
@@ -1027,10 +1028,22 @@ const message = {
instanceDeployment: '按实例部署',
engines: '本地推理引擎',
modelManagement: '模型管理',
ollamaManagement: 'Ollama 管理',
vllmManagement: 'vLLM 管理',
serviceDetails: '服务详情',
deployments: '本地部署',
files: '模型广场',
deployments: '模型部署',
files: '模型准备',
connectExternal: '接入外部服务',
recipes: 'vLLM Recipes',
createRecipe: '创建 Recipe',
recipeHelper: 'Recipe = 模型 + 硬件 + 启动参数。支持从 {link} 导入或从已下载模型创建。',
recipeFormHelper: '从 {link} 复制命令导入,或手动填写。',
recipeArguments: '启动参数',
recipeImportCommand: '导入命令',
recipeImportPlaceholder: '粘贴 Docker、pip/uv 或 vllm serve 命令,自动填充 Recipe',
recipeVariablesNotSupported: '命令中不支持变量引用,请填写具体值;环境变量请在环境变量栏配置。',
recipeImportError:
'无法转换,请提供包含模型的 Docker 或 vllm serve 启动命令;pip/uv 安装命令需要与启动命令一起粘贴。',
deploymentTemplates: 'vLLM 启动命令模板',
engineProvider: '引擎 / 供应商',
endpoint: '端点',
@@ -1057,7 +1070,6 @@ const message = {
vllmDeployProfile: '部署模型',
vllmProfileFlash0731: 'DeepSeek V4 Flash 0731',
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '启动命令必须使用 {0} 端口,否则服务将无法访问。',
ascendVisibleDevices: 'Ascend 可见设备(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型服务',
modelAccountAddressHelper:
@@ -1065,6 +1077,8 @@ const message = {
containerAddress: '容器地址',
customAddress: '自定义地址',
downloader: '下载管理',
modelDirectoryPlaceholder: '搜索模型或输入完整路径',
selectModelDirectory: '选择目录',
modelDir: '模型目录',
modelSource: '模型来源',
repoID: '仓库 ID',
@@ -4332,6 +4346,7 @@ const message = {
brotliHelper: '开启 brotli 压缩,通常比 gzip 体积更小',
brotliCompLevelHelper: 'brotli 压缩率,取值 0 到 11',
brotliManagedExternallyHelper: 'brotli 已在 nginx.conf 中手动配置,面板仅显示当前生效值,不会覆盖。',
brotliManagedPendingHelper: 'brotli 配置尚未生效,点击保存后生效。',
brotliManagedUnavailableHelper: '面板无法自动向 nginx.conf 写入托管的 brotli 配置,以下设置不会生效。',
brotliMinLengthHelper: 'brotli 压缩的最小响应大小',
brotliSaveFailed: 'brotli 设置保存失败,上方的 gzip 设置已应用',
+11 -13
View File
@@ -12,13 +12,23 @@ const aiRouter = {
title: 'menu.aiTools',
},
children: [
{
path: '/ai/model/local/ollama',
name: 'OllamaModels',
component: () => import('@/views/ai/model/ollama/index.vue'),
meta: {
icon: 'p-moxing-menu',
title: 'aiTools.model.ollamaManagement',
permission: 'ai_model_view',
},
},
{
path: '/ai/model',
name: 'AIModel',
redirect: '/ai/model/files',
meta: {
icon: 'p-moxing-menu',
title: 'aiTools.model.model',
title: 'aiTools.model.vllmManagement',
activeMenu: '/ai/model',
permission: 'ai_model_view',
},
@@ -56,18 +66,6 @@ const aiRouter = {
permission: 'ai_model_view',
},
},
{
path: '/ai/model/local/ollama',
hidden: true,
name: 'OllamaModels',
component: () => import('@/views/ai/model/ollama/index.vue'),
meta: {
title: 'aiTools.model.engines',
detail: 'aiTools.model.modelManagement',
activeMenu: '/ai/model',
permission: 'ai_model_view',
},
},
{
path: '/ai/agents/agent',
name: 'Agents',
-3
View File
@@ -38,9 +38,6 @@ export const jumpToInstall = (type: string, key: string) => {
},
});
return true;
case 'ollama':
router.push({ path: '/ai/model/local', query: { engine: 'ollama', uncached: 'true', open: 'create' } });
return true;
case 'vllm':
if (globalStore.isProductPro) {
router.push({
@@ -1,43 +1,9 @@
<template>
<DrawerPro
v-model="visible"
:header="$t(enginesOnly ? 'aiTools.model.addEngine' : 'aiTools.model.addService')"
:header="$t(enginesOnly ? 'commons.button.add' : 'aiTools.model.addService')"
:size="isMobile ? 'full' : '50%'"
>
<div v-if="selected !== 'account' && !vllmOnly" class="engine-options">
<el-button
class="engine-option"
:class="{ 'is-selected': selected === 'vllm' }"
:aria-pressed="selected === 'vllm'"
@click="selected = 'vllm'"
>
<ProviderLogo provider="vllm" :size="18" />
<span class="engine-content">
<span class="engine-name">vLLM</span>
<span class="engine-description">{{ $t('aiTools.model.instanceDeployment') }}</span>
</span>
</el-button>
<el-button
v-if="!vllmOnly"
class="engine-option"
:class="{ 'is-selected': selected === 'ollama' }"
:aria-pressed="selected === 'ollama'"
@click="selected = 'ollama'"
>
<ProviderLogo provider="ollama" :size="18" />
<span class="engine-content">
<span class="engine-name">Ollama</span>
<span class="engine-description" :class="{ 'engine-running': ollama?.status === 'Running' }">
{{
ollama?.isExist
? $t('commons.status.' + ollama.status.toLowerCase())
: $t('aiTools.model.modelManagement')
}}
</span>
</span>
</el-button>
</div>
<el-divider v-if="selected !== 'account' && !vllmOnly" />
<DockerStatus
v-if="visible && selected !== 'account'"
v-model:is-active="dockerActive"
@@ -61,76 +27,12 @@
@close="visible = false"
@task="openTask"
/>
<OllamaForm
v-if="activeForm === 'ollama'"
ref="ollamaRef"
inline
@search="emit('search')"
@close="visible = false"
@task="openTask"
/>
<template v-if="selected === 'vllm' && (!isProductPro || !vllmAvailable)">
<el-alert :title="$t('license.vllm')" type="info" :closable="false" />
<el-button class="mt-4" type="primary" @click="openVllmLicense">
{{ $t('license.levelUpPro') }}
</el-button>
</template>
<section v-if="selected === 'ollama' && ollama?.isExist" class="installed-engine">
<div
class="installed-engine-overview"
role="link"
tabindex="0"
:aria-label="$t('aiTools.model.modelManagement')"
@click="openOllama"
@keydown.enter.self.prevent="openOllama"
@keydown.space.self.prevent="openOllama"
>
<div class="installed-engine-heading">
<ProviderLogo provider="ollama" :size="40" />
<div class="installed-engine-identity">
<h3>{{ ollama.name }}</h3>
<span>Ollama · {{ ollama.version }}</span>
</div>
<Status :status="ollama.status" />
</div>
<div class="installed-engine-actions" @click.stop>
<el-button type="primary" plain @click="openOllamaConnection">
{{ $t('database.databaseConnInfo') }}
</el-button>
<el-button :loading="openingWebUI" @click="openWebUI">OpenWebUI</el-button>
<el-button @click="openOllama">
{{ $t('aiTools.model.modelManagement') }}
</el-button>
</div>
</div>
<h4 class="installed-engine-title">{{ $t('aiTools.model.serviceDetails') }}</h4>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('app.containerName')">
<div class="installed-engine-value">
<code>{{ ollama.containerName }}</code>
<CopyButton :content="ollama.containerName" />
</div>
</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.port')">
{{ ollama.httpPort || '—' }}
</el-descriptions-item>
<el-descriptions-item :label="$t('aiTools.model.endpoint')">
<div class="installed-engine-value">
<code>{{ ollama.modelAccountBaseURL || '—' }}</code>
<CopyButton v-if="ollama.modelAccountBaseURL" :content="ollama.modelAccountBaseURL" />
</div>
</el-descriptions-item>
<el-descriptions-item :label="$t('xpack.node.baseDir')">
<div class="installed-engine-value">
<code>{{ ollama.installPath || '—' }}</code>
<CopyButton v-if="ollama.installPath" :content="ollama.installPath" />
</div>
</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.createdAt')">
{{ ollama.createdAt ? dateFormatSimple(ollama.createdAt) : '—' }}
</el-descriptions-item>
</el-descriptions>
</section>
</div>
<template v-if="activeForm" #footer>
<el-button :disabled="currentForm?.loading" @click="visible = false">
@@ -146,54 +48,34 @@
{{ $t('commons.button.confirm') }}
</el-button>
</template>
<template v-else-if="selected === 'ollama' && ollama?.isExist" #footer>
<el-button @click="visible = false">{{ $t('commons.button.close') }}</el-button>
</template>
</DrawerPro>
<EngineConnection ref="connectionRef" />
<PortJumpDialog ref="portRef" />
<AccountForm ref="editAccountRef" @search="emit('search')" />
<TaskLog ref="taskRef" @close="emit('search')" />
</template>
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { useRouter } from 'vue-router';
import ProviderLogo from '@/components/agent-provider-logo/index.vue';
import type { AI } from '@/api/interface/ai';
import type { App } from '@/api/interface/app';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { loadOptionalComponent } from '@/extensions/optional';
import AccountForm from '../account-form/index.vue';
import OllamaForm from '@/views/ai/model/ollama/operate/index.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import { checkAppInstalled, getAppPort } from '@/api/modules/app';
import { dateFormatSimple } from '@/utils/date';
import i18n from '@/lang';
import EngineConnection from '../connection/index.vue';
import PortJumpDialog from '@/components/port-jump/index.vue';
import { vllmAvailable } from '../../deployment';
const props = defineProps({ enginesOnly: Boolean, vllmOnly: Boolean });
const props = defineProps({ enginesOnly: Boolean });
const emit = defineEmits(['search', 'manage']);
const { isProductPro, isMobile, currentNode } = useGlobalStore();
const router = useRouter();
const { isProductPro, isMobile } = useGlobalStore();
const visible = ref(false);
const selected = ref<'account' | 'vllm' | 'ollama'>('account');
const selected = ref<'account' | 'vllm'>('account');
const modelDir = ref('');
const recipe = ref<{ id?: number; imageType: string }>();
const dockerActive = ref(false);
const dockerExists = ref(true);
const ollama = ref<App.CheckInstalled>();
const accountRef = ref();
const editAccountRef = ref();
const ollamaRef = ref();
const vllmRef = ref();
const taskRef = ref();
const connectionRef = ref();
const portRef = ref();
const openingWebUI = ref(false);
const vllmForm = shallowRef();
const initializing = ref(false);
const loadFailed = ref(false);
@@ -202,14 +84,12 @@ const activeForm = computed(() => {
if (selected.value === 'account') return 'account';
if (!dockerActive.value || !dockerExists.value) return '';
if (selected.value === 'vllm' && isProductPro.value && vllmAvailable) return 'vllm';
if (selected.value === 'ollama' && ollama.value && !ollama.value.isExist) return 'ollama';
return '';
});
const currentForm = computed(() => {
if (activeForm.value === 'account') return accountRef.value;
if (activeForm.value === 'vllm') return vllmRef.value;
if (activeForm.value === 'ollama') return ollamaRef.value;
return undefined;
});
@@ -230,8 +110,7 @@ watch(
await nextTick();
if (cancelled) return;
if (form === 'account') await accountRef.value.open();
if (form === 'vllm') await vllmRef.value.openCreate(modelDir.value);
if (form === 'ollama') await ollamaRef.value.openCreate();
if (form === 'vllm') await vllmRef.value.openCreate(modelDir.value, recipe.value);
} catch {
if (!cancelled) loadFailed.value = true;
} finally {
@@ -241,205 +120,31 @@ watch(
{ flush: 'post' },
);
const open = async (engine?: 'ollama' | 'vllm', defaultModelDir = '') => {
const open = (engine?: 'vllm', defaultModelDir = '', defaultRecipe?: { id?: number; imageType: string }) => {
loadFailed.value = false;
modelDir.value = defaultModelDir;
selected.value = props.vllmOnly ? 'vllm' : engine || (props.enginesOnly ? 'vllm' : 'account');
recipe.value = defaultRecipe;
selected.value = engine || (props.enginesOnly ? 'vllm' : 'account');
dockerActive.value = false;
dockerExists.value = true;
ollama.value = undefined;
visible.value = true;
if (selected.value === 'account' || props.vllmOnly) return;
const { data } = await checkAppInstalled('ollama', '');
if (visible.value) ollama.value = data;
};
const openAccount = (account: AI.AgentAccountItem) => {
editAccountRef.value.open({ ...account, baseURL: account.baseUrl });
};
const openOllama = async () => {
if (!ollama.value) return;
visible.value = false;
emit('manage', { engine: 'ollama', id: ollama.value.appInstallId, name: ollama.value.name });
};
const openOllamaConnection = () => {
if (!ollama.value?.isExist) return;
connectionRef.value.acceptParams({
name: ollama.value.name,
engine: 'ollama',
port: ollama.value.httpPort,
containerName: ollama.value.containerName,
appinstallID: ollama.value.appInstallId,
});
};
const openWebUI = async () => {
const node = currentNode.value;
openingWebUI.value = true;
try {
const { data } = await getAppPort('ollama-webui', '');
if (node !== currentNode.value || !visible.value || selected.value !== 'ollama') return;
if (data) {
await portRef.value.acceptParams({ port: data });
return;
}
const confirmed = await ElMessageBox.confirm(
i18n.global.t('app.checkInstalledWarn', ['OpenWebUI']),
'OpenWebUI',
{
confirmButtonText: i18n.global.t('database.goInstall'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
).catch(() => false);
if (!confirmed || node !== currentNode.value || !visible.value || selected.value !== 'ollama') return;
visible.value = false;
await router.push({ name: 'AppAll', query: { install: 'ollama-webui' } });
} finally {
openingWebUI.value = false;
}
};
const openVllmLicense = () => {
visible.value = false;
emit('manage', { engine: 'vllm', id: 0, name: '' });
emit('manage', { id: 0, name: '' });
};
const openTask = (taskID: string) => taskRef.value.openWithTaskID(taskID);
defineExpose({ open, openAccount });
</script>
<style scoped lang="scss">
.engine-options {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 6px;
}
.engine-option {
min-width: 0;
height: auto;
margin: 0;
padding: 10px 8px;
border-radius: 6px;
text-align: left;
white-space: normal;
:deep(> span) {
width: 100%;
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.el-icon {
flex-shrink: 0;
color: var(--el-color-primary);
margin-top: 2px;
}
&.is-selected {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
.engine-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.engine-name {
font-size: 12px;
font-weight: 600;
}
.engine-description {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.4;
}
.engine-name,
.engine-description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.engine-running {
color: var(--el-color-success);
}
<style scoped>
.service-form {
min-height: 100px;
}
.installed-engine-overview {
padding: 20px;
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
cursor: pointer;
&:hover {
border-color: var(--el-color-primary);
}
&:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
}
.installed-engine-heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14px;
}
.installed-engine-identity {
flex: 1;
min-width: 0;
h3 {
margin: 0 0 6px;
font-size: 16px;
overflow-wrap: anywhere;
}
span {
color: var(--el-text-color-secondary);
font-size: 12px;
}
}
.installed-engine-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 20px;
.el-button {
margin-left: 0;
}
}
.installed-engine-title {
margin: 24px 0 12px;
font-size: 14px;
font-weight: 600;
}
.installed-engine {
:deep(.el-descriptions__table) {
table-layout: fixed;
}
:deep(.el-descriptions__label) {
width: 110px;
}
}
.installed-engine-value {
display: flex;
align-items: flex-start;
gap: 8px;
code {
min-width: 0;
overflow-wrap: anywhere;
}
:deep(.el-button) {
flex-shrink: 0;
}
}
@media (max-width: 600px) {
.engine-options {
grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -11,14 +11,9 @@
<el-alert v-if="failed" :title="$t('aiTools.model.loadFailed')" type="error" :closable="false" />
<el-empty v-else-if="missing" :description="$t('commons.res.notFound')" />
<template v-else-if="visible && instance && !loading && view !== VllmPlaceholder">
<div v-if="instance.engine !== 'ollama'" class="service-card">
<div class="service-card">
<div class="flex flex-wrap gap-3 items-center mb-4">
<strong>
{{ engineLabels[instance.engine] }}
{{
instance.engine === 'vllm' ? formatVllmVersion(instance.version) : instance.version
}}
</strong>
<strong>vLLM {{ formatVllmVersion(instance.appVersion) }}</strong>
<Status :status="instance.status" />
</div>
<div class="service-actions">
@@ -36,7 +31,7 @@
</el-button>
</div>
</div>
<el-descriptions v-if="instance.engine !== 'ollama'" :column="1" border>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('commons.table.name')">
{{ instance.name }}
</el-descriptions-item>
@@ -57,17 +52,15 @@
v-if="visible && view && view !== VllmPlaceholder && !failed && !missing"
ref="contentRef"
v-bind="
view === AppParams
? { modelGPU: true }
: view === EngineConnection
? {}
: view === ContainerLogs
? { highlightDiff: 210 }
: view === TaskLog
? { width: '70%' }
: ['config', 'monitor'].includes(actionName)
? {}
: { dialog: true }
view === EngineConnection
? {}
: view === ContainerLogs
? { highlightDiff: 210 }
: view === TaskLog
? { width: '70%' }
: ['config', 'monitor'].includes(actionName)
? {}
: { dialog: true }
"
@close="returnToManagement"
@search="emit('search')"
@@ -79,7 +72,7 @@
size="small"
>
<p>{{ $t('commons.msg.delete', [deleting?.name || '']) }}</p>
<el-checkbox v-if="deleting?.engine === 'vllm'" v-model="forceDelete" :label="$t('website.forceDelete')" />
<el-checkbox v-model="forceDelete" :label="$t('website.forceDelete')" />
<template #footer>
<el-button :disabled="deleteLoading" @click="deleteVisible = false">
{{ $t('commons.button.cancel') }}
@@ -96,14 +89,12 @@ import { computed, markRaw, nextTick, ref, shallowRef, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { loadOptionalComponent } from '@/extensions/optional';
import { checkAppInstalled, searchAppInstalled } from '@/api/modules/app';
import { searchAppInstalled } from '@/api/modules/app';
import i18n from '@/lang';
import { MsgError } from '@/utils/message';
import { hasPermission } from '@/utils/rbac';
import { hasManagePermissionAccess } from '@/utils/permission';
import { newUUID } from '@/utils/id';
import { routerToName } from '@/utils/router';
import AppParams from '@/views/app-store/installed/detail/index.vue';
import EngineConnection from '../connection/index.vue';
import VllmPlaceholder from '../../vllm/index.vue';
import ContainerLogs from '@/components/log/container-drawer/index.vue';
@@ -125,14 +116,14 @@ const loading = ref(false);
const failed = ref(false);
const missing = ref(false);
const header = ref('');
const instance = ref<Deployment>();
type DeploymentDetails = Pick<Deployment, 'id' | 'name'> & Partial<Deployment>;
const instance = ref<DeploymentDetails>();
const actionName = ref('manage');
const deleteVisible = ref(false);
const deleting = ref<Deployment>();
const deleting = ref<DeploymentDetails>();
const forceDelete = ref(false);
const deleteLoading = ref(false);
const deleted = ref(false);
const engineLabels = { vllm: 'vLLM', ollama: 'Ollama' };
const view = shallowRef();
const contentRef = ref();
let request = 0;
@@ -158,74 +149,26 @@ const openUpgrade = async (installId: number, version: number) => {
if (standalone.value) visible.value = false;
emit('upgrade', app);
};
const open = async (
row: Pick<Deployment, 'engine' | 'id' | 'name'> & Partial<Deployment>,
action = 'manage',
outside = false,
) => {
if (row.engine === 'ollama' && action === 'manage') {
visible.value = false;
await routerToName('OllamaModels');
return;
}
const open = async (row: DeploymentDetails, action = 'manage', outside = false) => {
const version = ++request;
standalone.value = outside;
instance.value = { version: '', ports: [], containerName: '', status: '', modelDir: '', models: [], ...row };
instance.value = { ...row };
actionName.value = action;
deleted.value = false;
header.value = `${engineLabels[row.engine]} ${i18n.global.t('aiTools.model.manage')}`;
header.value = `vLLM ${i18n.global.t('aiTools.model.manage')}`;
view.value = undefined;
failed.value = false;
missing.value = false;
visible.value = true;
loading.value = true;
try {
if (row.engine === 'vllm' && (!isProductPro.value || !vllmAvailable)) {
if (!isProductPro.value || !vllmAvailable) {
view.value = markRaw(VllmPlaceholder);
return;
}
if (row.engine === 'ollama') {
const { data } = await checkAppInstalled('ollama', row.name);
if (version !== request || !visible.value) return;
if (!data.isExist) {
missing.value = true;
if (standalone.value) MsgError(i18n.global.t('commons.res.notFound'));
return;
}
instance.value = {
...instance.value,
id: data.appInstallId,
version: data.version,
status: data.status,
taskID: data.taskID,
containerName: data.containerName,
};
if (data.taskID && ['config', 'upgrade', 'log'].includes(action)) {
await openTask(data.taskID);
return;
}
if (action === 'upgrade') {
await openUpgrade(data.appInstallId, version);
return;
}
const component = { config: AppParams, connection: EngineConnection, log: ContainerLogs }[action];
view.value = component ? markRaw(component) : undefined;
await nextTick();
if (version !== request || !visible.value) return;
if (action === 'config') await contentRef.value?.acceptParams({ id: data.appInstallId, edit: true });
if (action === 'connection')
contentRef.value?.acceptParams({
name: data.name,
engine: 'ollama',
port: data.httpPort,
containerName: data.containerName,
appinstallID: data.appInstallId,
});
if (action === 'log')
contentRef.value?.acceptParams({ containerID: data.containerName, container: data.containerName });
return;
}
const items = await readPages((page) => loadVllmAPI().then((api) => api.pageVllm({ ...page, info: row.name })));
const api = await loadVllmAPI();
if (version !== request || !visible.value) return;
const items = await readPages((page) => api.pageVllm({ ...page, info: row.name }));
const detail = items.find((item) => item.id === row.id);
if (version !== request || !visible.value) return;
if (!detail) {
@@ -233,15 +176,7 @@ const open = async (
if (standalone.value) MsgError(i18n.global.t('commons.res.notFound'));
return;
}
instance.value = {
...instance.value,
status: detail.status,
taskID: detail.taskID,
containerName: detail.containerName,
modelDir: detail.modelDir,
version: detail.appVersion,
upgradable: detail.upgradable,
};
instance.value = detail;
if (detail.taskID && ['config', 'upgrade', 'delete'].includes(action)) {
await openTask(detail.taskID);
return;
@@ -254,7 +189,7 @@ const open = async (
port: detail.port,
containerPort: '',
endpoint: detail.modelAccountBaseURL,
engine: row.engine,
engine: 'vllm',
name: row.name,
modelDir: detail.modelDir,
});
@@ -306,7 +241,7 @@ watch([currentNode, () => route.fullPath, isProductPro], () => {
deleteVisible.value = false;
deleting.value = undefined;
});
const operationDisabled = (row: Deployment) => Boolean(deleteLoading.value || row.taskID);
const operationDisabled = (row: DeploymentDetails) => Boolean(deleteLoading.value || row.taskID);
const deleteDeployment = async () => {
const version = request;
const row = deleting.value;
@@ -332,25 +267,24 @@ const deploymentButtons = [
label: i18n.global.t('commons.button.edit'),
permission: true,
disabled: operationDisabled,
click: (row: Deployment) => open(row, 'config'),
click: (row: DeploymentDetails) => open(row, 'config'),
},
{
label: i18n.global.t('menu.monitor'),
show: (row: Deployment) => row.engine === 'vllm',
click: (row: Deployment) => open(row, 'monitor'),
click: (row: DeploymentDetails) => open(row, 'monitor'),
},
{
label: i18n.global.t('commons.button.upgrade'),
permission: true,
show: (row: Deployment) => row.upgradable && hasPermission('app_manage'),
show: (row: DeploymentDetails) => row.upgradable && hasPermission('app_manage'),
disabled: operationDisabled,
click: (row: Deployment) => open(row, 'upgrade'),
click: (row: DeploymentDetails) => open(row, 'upgrade'),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
disabled: (row: Deployment) => operationDisabled(row),
click: (row: Deployment) => {
disabled: (row: DeploymentDetails) => operationDisabled(row),
click: (row: DeploymentDetails) => {
deleting.value = row;
forceDelete.value = false;
deleteVisible.value = true;
@@ -31,8 +31,6 @@
import { onBeforeUnmount, reactive, ref, watch } from 'vue';
import type { FormInstance } from 'element-plus';
import type { AI } from '@/api/interface/ai';
import { syncOllamaModelAccount } from '@/api/modules/ai';
import { checkAppInstalled } from '@/api/modules/app';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { useRoute } from 'vue-router';
import i18n from '@/lang';
@@ -50,7 +48,6 @@ const formRef = ref<FormInstance>();
const addressRef = ref<InstanceType<typeof AccountAddress>>();
const form = reactive({
id: 0,
engine: 'ollama' as 'ollama' | 'vllm',
name: '',
containerName: '',
hostPort: 0,
@@ -68,55 +65,34 @@ const close = () => {
};
const open = async (row: Deployment) => {
if (row.engine !== 'ollama' && row.engine !== 'vllm') return;
const version = ++request;
form.id = 0;
form.name = row.name;
form.engine = row.engine;
visible.value = true;
loading.value = true;
try {
if (row.engine === 'ollama') {
const { data: item } = await checkAppInstalled('ollama', row.name);
if (version !== request) return;
if (!item.isExist || item.appInstallId !== row.id) {
MsgError(i18n.global.t('commons.res.notFound'));
return;
}
Object.assign(form, {
id: item.appInstallId,
containerName: item.containerName,
hostPort: item.httpPort,
containerPort: '11434',
hostMode: false,
modelAccountBaseURLType: item.modelAccountBaseURLType || 'container',
modelAccountBaseURL: item.modelAccountBaseURL || '',
});
} else {
const api = await loadVllmAPI();
if (version !== request) return;
const items = await readPages((page) => api.pageVllm({ ...page, info: row.name }));
if (version !== request) return;
const item = items.find((item) => item.id === row.id);
if (!item) {
MsgError(i18n.global.t('commons.res.notFound'));
return;
}
const hostMode = item.appVersion.trim().toLowerCase().startsWith('nvidia-gb10-dspark-');
Object.assign(form, {
id: item.id,
containerName: item.containerName,
hostPort: item.port,
containerPort:
item.command.match(/--port\s+(\d+)/)?.[1] || (item.imageType === 'intel' ? '8001' : '8000'),
hostMode,
modelAccountBaseURLType:
hostMode && item.modelAccountBaseURLType === 'container'
? 'systemIP'
: item.modelAccountBaseURLType || (hostMode ? 'systemIP' : 'container'),
modelAccountBaseURL: item.modelAccountBaseURL || '',
});
const api = await loadVllmAPI();
if (version !== request) return;
const items = await readPages((page) => api.pageVllm({ ...page, info: row.name }));
if (version !== request) return;
const item = items.find((item) => item.id === row.id);
if (!item) {
MsgError(i18n.global.t('commons.res.notFound'));
return;
}
const hostMode = item.appVersion.trim().toLowerCase().startsWith('nvidia-gb10-dspark-');
Object.assign(form, {
id: item.id,
containerName: item.containerName,
hostPort: item.port,
containerPort: item.command.match(/--port\s+(\d+)/)?.[1] || (item.imageType === 'intel' ? '8001' : '8000'),
hostMode,
modelAccountBaseURLType:
hostMode && item.modelAccountBaseURLType === 'container'
? 'systemIP'
: item.modelAccountBaseURLType || (hostMode ? 'systemIP' : 'container'),
modelAccountBaseURL: item.modelAccountBaseURL || '',
});
} finally {
if (version === request) loading.value = false;
}
@@ -134,13 +110,9 @@ const submit = async () => {
modelAccountBaseURLType: form.modelAccountBaseURLType,
modelAccountBaseURL: addressRef.value.baseURL,
};
if (form.engine === 'ollama') {
await syncOllamaModelAccount(req);
} else {
const api = await loadVllmAPI();
if (version !== request || !visible.value) return;
await api.operateVllm({ ...req, operate: 'sync', taskID: '' });
}
const api = await loadVllmAPI();
if (version !== request || !visible.value) return;
await api.operateVllm({ ...req, operate: 'sync', taskID: '' });
if (version !== request) return;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('search');
+1 -59
View File
@@ -1,23 +1,7 @@
import type { ReqPage, ResPage, ResultData } from '@/api/interface';
import type { AI } from '@/api/interface/ai';
import { searchOllamaModel } from '@/api/modules/ai';
import { checkAppInstalled, searchAppInstalled } from '@/api/modules/app';
export interface Deployment {
taskID?: string;
id: number;
engine: 'vllm' | 'ollama';
name: string;
version: string;
ports: number[];
containerName: string;
modelDir: string;
models: string[];
status: string;
upgradable?: boolean;
}
interface VllmItem {
taskID?: string;
appInstallId: number;
agentAccountId: number;
@@ -38,7 +22,7 @@ interface VllmItem {
}
interface VllmAPI {
pageVllm: (req: ReqPage & { info: string }) => Promise<ResultData<ResPage<VllmItem>>>;
pageVllm: (req: ReqPage & { info: string }) => Promise<ResultData<ResPage<Deployment>>>;
operateVllm: (
req: { id: number; operate: string; taskID: string; forceDelete?: boolean } & Partial<AI.ModelAccountSync>,
) => Promise<unknown>;
@@ -68,45 +52,3 @@ export async function readPages<T>(search: (page: ReqPage) => Promise<ResultData
}
}
}
export async function loadModelOverview(pro: boolean) {
const [vllm, ollama] = await Promise.all([
pro && vllmAvailable
? loadVllmAPI().then((api) => readPages((page) => api.pageVllm({ ...page, info: '' })))
: Promise.resolve([]),
checkAppInstalled('ollama', ''),
]);
const deployments: Deployment[] = vllm.map((item) => ({
id: item.id,
engine: 'vllm',
upgradable: item.upgradable,
name: item.name,
version: item.appVersion,
ports: item.port > 0 ? [item.port] : [],
containerName: item.containerName,
modelDir: item.modelDir,
models: item.servedModelName ? [item.servedModelName] : [],
status: item.status,
taskID: item.taskID,
}));
if (ollama.data.isExist) {
const [models, updates] = await Promise.all([
readPages<AI.OllamaModelInfo>((page) => searchOllamaModel(page)),
readPages((page) => searchAppInstalled({ ...page, name: ollama.data.name, update: true })),
]);
deployments.push({
id: ollama.data.appInstallId,
engine: 'ollama',
name: ollama.data.name || 'Ollama',
version: ollama.data.version,
ports: ollama.data.httpPort > 0 ? [ollama.data.httpPort] : [],
upgradable: updates.some((app) => app.id === ollama.data.appInstallId && app.canUpdate),
containerName: ollama.data.containerName,
modelDir: '',
models: models.filter((model) => model.status === 'Success').map((model) => model.name),
status: ollama.data.status,
taskID: ollama.data.taskID,
});
}
return deployments;
}
+55 -195
View File
@@ -6,14 +6,7 @@
<LayoutContent v-loading="loading">
<template #leftToolBar>
<el-button v-permission type="primary" @click="createRef.open()">
{{ $t(isLocal ? 'aiTools.model.addEngine' : 'aiTools.model.addService') }}
</el-button>
<el-button
v-if="isLocal && isProductPro && vllmAvailable"
v-permission
@click="routerToName('VllmCommandTemplates')"
>
{{ $t('aiTools.model.deploymentTemplates') }}
{{ $t(isLocal ? 'commons.button.add' : 'aiTools.model.addService') }}
</el-button>
</template>
<template #rightToolBar>
@@ -30,19 +23,7 @@
<el-option v-for="item in apiTypeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-if="isLocal"
v-model="engine"
clearable
placeholder=""
class="model-filter"
@change="resetPageAndSearch"
>
<template #prefix>{{ $t('aiTools.model.engines') }}</template>
<el-option label="Ollama" value="ollama" />
<el-option label="vLLM" value="vllm" />
</el-select>
<el-select
v-else
v-if="!isLocal"
v-model="provider"
clearable
filterable
@@ -179,12 +160,7 @@
width="220"
/>
</ComplexTable>
<ComplexTable
v-else
:data="deploymentPage"
:pagination-config="pagination"
@search="resetPageIfNeeded"
>
<ComplexTable v-else :data="deployments" :pagination-config="pagination" @search="search">
<el-table-column :label="$t('commons.table.name')" min-width="190">
<template #default="{ row }">
<el-button link type="primary" @click="manageRef.open(row, 'connection', true)">
@@ -195,12 +171,7 @@
<el-table-column :label="$t('app.version')" min-width="150">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span>
{{
(row.engine === 'vllm' ? formatVllmVersion(row.version) : row.version) ||
'—'
}}
</span>
<span>{{ formatVllmVersion(row.appVersion) || '—' }}</span>
<el-button
v-if="row.upgradable && hasPermission('app_manage')"
v-permission
@@ -217,57 +188,23 @@
</el-table-column>
<el-table-column :label="$t('commons.table.port')" min-width="120">
<template #default="{ row }">
<span v-if="!row.ports.length">—</span>
<div v-else class="space-y-1">
<div
v-for="(port, index) in row.ports.slice(0, 2)"
:key="index"
class="flex items-center gap-2"
>
<el-button
icon="Position"
plain
size="small"
@click="portJumpRef.acceptParams({ port, ip: '', protocol: 'http' })"
>
{{ port }}
</el-button>
<el-popover
v-if="index === 1 && row.ports.length > 2"
placement="bottom"
trigger="click"
:title="$t('commons.table.port')"
:width="160"
>
<template #reference>
<el-button size="small" class="shrink-0">
+{{ row.ports.length - 2 }}
</el-button>
</template>
<div class="max-h-60 overflow-y-auto space-y-1">
<div v-for="(item, portIndex) in row.ports" :key="portIndex">
{{ item }}
</div>
</div>
</el-popover>
</div>
</div>
<el-button
v-if="row.port > 0"
icon="Position"
plain
size="small"
@click="portJumpRef.acceptParams({ port: row.port, ip: '', protocol: 'http' })"
>
{{ row.port }}
</el-button>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.model.model')" min-width="200">
<template #default="{ row }">
<div v-for="(model, index) in row.models.slice(0, 2)" :key="model" class="model-line">
<span class="cell-ellipsis" :title="model">{{ model }}</span>
<el-button
v-if="row.engine === 'ollama' && index === 1 && row.models.length > 2"
size="small"
class="model-more"
@click="manageDeployment(row)"
>
+{{ row.models.length - 2 }}
</el-button>
<div class="cell-ellipsis" :title="row.servedModelName">
{{ row.servedModelName || '—' }}
</div>
<span v-if="!row.models.length">—</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="150">
@@ -339,21 +276,13 @@
</template>
</LayoutContent>
</template>
<CreateService
ref="createRef"
:engines-only="isLocal || isFiles"
:vllm-only="isFiles"
@search="search"
@manage="manageDeployment"
/>
<CreateService ref="createRef" :engines-only="isLocal || isFiles" @search="search" @manage="manageDeployment" />
<ManageDeployment
ref="manageRef"
@search="search"
@upgrade="(app) => upgradeRef.acceptParams(app, 'upgrade')"
/>
<AppUpgrade ref="upgradeRef" @close="search" />
<AppDelete :key="`delete:${currentNode}:${route.fullPath}`" ref="appDeleteRef" @close="search" />
<AppResources :key="`resources:${currentNode}:${route.fullPath}`" ref="appResourcesRef" @close="search" />
<TaskLog ref="taskRef" width="70%" @close="search" />
<ModelPool ref="poolRef" @updated="search" />
<TerminalDialog :key="`terminal:${currentNode}:${route.fullPath}`" ref="terminalRef" />
@@ -376,32 +305,20 @@ import { deleteAgentAccount, getAgentProviders, pageAgentAccounts } from '@/api/
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getAgentProviderDisplayName, isAgentAccountVerificationSkipped } from '@/utils/agent';
import i18n from '@/lang';
import { appInstalledDeleteCheck, installedOp, searchAppInstalled } from '@/api/modules/app';
import { hasPermission } from '@/utils/rbac';
import { hasManagePermissionAccess } from '@/utils/permission';
import { newUUID } from '@/utils/id';
import { dateFormat } from '@/utils/date';
import { useSearchState } from '@/utils/search-reset';
import { MsgError } from '@/utils/message';
import { routerToName } from '@/utils/router';
import TaskLog from '@/components/log/task/index.vue';
import CreateService from './components/create/index.vue';
import ManageDeployment from './components/deployment-manage/index.vue';
import AppUpgrade from '@/views/app-store/installed/upgrade/index.vue';
import AppDelete from '@/views/app-store/installed/delete/index.vue';
import AppResources from '@/views/app-store/installed/check/index.vue';
import ModelPool from './components/model-pool/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import SyncService from './components/sync/index.vue';
import ProviderLabel from '@/components/agent-provider-label/index.vue';
import {
formatVllmVersion,
loadModelOverview,
loadVllmAPI,
readPages,
vllmAvailable,
type Deployment,
} from './deployment';
import { formatVllmVersion, loadVllmAPI, vllmAvailable, type Deployment } from './deployment';
const route = useRoute();
const router = useRouter();
@@ -412,15 +329,13 @@ const isFiles = pagePath === '/ai/model/files';
const isLocal = pagePath === '/ai/model/local';
const tabs = computed(() => [
{ label: i18n.global.t('aiTools.model.files'), path: '/ai/model/files' },
{ label: i18n.global.t('aiTools.model.deployments'), path: '/ai/model/local' },
{ label: i18n.global.t('aiTools.model.services'), path: '/ai/model/account' },
{ label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' },
]);
const downloaderView = defineAsyncComponent(() => import('@/views/model-downloader/index.vue'));
const createRef = ref();
const manageRef = ref();
const upgradeRef = ref();
const appDeleteRef = ref();
const appResourcesRef = ref();
const poolRef = ref();
const terminalRef = ref<InstanceType<typeof TerminalDialog>>();
const syncRef = ref<InstanceType<typeof SyncService>>();
@@ -447,7 +362,6 @@ watch(
);
const deployments = ref<Deployment[]>([]);
const apiType = ref('');
const engine = ref('');
const provider = ref('');
const searchName = ref('');
const pagination = reactive({ currentPage: 1, pageSize: 10, total: 0 });
@@ -483,23 +397,9 @@ const providerOptions = computed(() =>
label: getAgentProviderDisplayName(item.provider, item.displayName),
})),
);
const filteredDeployments = computed(() => {
const keyword = searchName.value.trim().toLowerCase();
return deployments.value.filter(
(row) =>
(!engine.value || row.engine === engine.value) &&
(!keyword || [row.name, row.modelDir, ...row.models].join(' ').toLowerCase().includes(keyword)),
);
});
const deploymentPage = computed(() =>
filteredDeployments.value.slice(
(pagination.currentPage - 1) * pagination.pageSize,
pagination.currentPage * pagination.pageSize,
),
);
const resourceStats = ref<Record<string, Container.ContainerListStats | null>>({});
watch(
[deploymentPage, isCurrentPage, currentNode],
[deployments, isCurrentPage, currentNode],
async ([rows, active, node], _, onCleanup) => {
resourceStats.value = {};
const controller = new AbortController();
@@ -540,12 +440,6 @@ const resetPageIfNeeded = () => {
Math.max(1, Math.ceil(pagination.total / pagination.pageSize)),
);
};
watch(filteredDeployments, () => {
if (!isLocal) return;
pagination.total = filteredDeployments.value.length;
resetPageIfNeeded();
});
let searchVersion = 0;
const search = async () => {
const version = ++searchVersion;
@@ -553,9 +447,21 @@ const search = async () => {
loading.value = true;
try {
if (isLocal) {
const data = await loadModelOverview(isProductPro.value);
if (!isProductPro.value || !vllmAvailable) {
deployments.value = [];
pagination.total = 0;
return;
}
const api = await loadVllmAPI();
if (version !== searchVersion) return;
deployments.value = data;
const { data } = await api.pageVllm({
page: pagination.currentPage,
pageSize: pagination.pageSize,
info: searchName.value,
});
if (version !== searchVersion) return;
deployments.value = data.items || [];
pagination.total = data.total;
} else {
const result = await pageAgentAccounts({
page: pagination.currentPage,
@@ -567,10 +473,10 @@ const search = async () => {
if (version !== searchVersion) return;
connections.value = result.data.items || [];
pagination.total = result.data.total;
const previousPage = pagination.currentPage;
resetPageIfNeeded();
if (previousPage !== pagination.currentPage) await search();
}
const previousPage = pagination.currentPage;
resetPageIfNeeded();
if (previousPage !== pagination.currentPage) await search();
} catch {
if (version !== searchVersion) return;
connections.value = [];
@@ -617,10 +523,9 @@ const accountButtons = [
},
];
const taskRef = ref();
const pending = ref('');
const pending = ref(false);
const operationDisabled = (row: Deployment) => Boolean(pending.value || row.taskID);
const statusDisabled = (row: Deployment) =>
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage'));
const statusDisabled = (row: Deployment) => operationDisabled(row) || !hasManagePermissionAccess();
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
if (statusDisabled(row)) return;
const node = currentNode.value;
@@ -630,46 +535,30 @@ const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart')
{ type: 'warning' },
);
if (node !== currentNode.value || statusDisabled(row)) return;
pending.value = `${row.engine}:${row.id}`;
pending.value = true;
try {
if (row.engine === 'ollama') {
const taskID = newUUID();
await installedOp({ installId: row.id, operate: operation, taskID });
if (node !== currentNode.value) return;
taskRef.value.openWithTaskID(taskID);
} else {
const taskID = newUUID();
await (await loadVllmAPI()).operateVllm({ id: row.id, operate: operation, taskID });
if (node !== currentNode.value) return;
taskRef.value.openWithTaskID(taskID);
}
const taskID = newUUID();
await (await loadVllmAPI()).operateVllm({ id: row.id, operate: operation, taskID });
if (node !== currentNode.value) return;
taskRef.value.openWithTaskID(taskID);
await search();
} finally {
pending.value = '';
pending.value = false;
}
};
const deploymentButtons = [
{
label: i18n.global.t('commons.button.edit'),
permission: true,
show: (row: Deployment) => row.engine === 'vllm' || (row.engine === 'ollama' && hasPermission('app_manage')),
disabled: operationDisabled,
click: (row: Deployment) => manageRef.value.open(row, 'config', true),
},
{
label: i18n.global.t('aiTools.model.manage'),
show: (row: Deployment) => row.engine !== 'vllm',
click: manageDeployment,
},
{
label: i18n.global.t('menu.monitor'),
show: (row: Deployment) => row.engine === 'vllm',
click: (row: Deployment) => manageRef.value.open(row, 'monitor', true),
},
{
label: i18n.global.t('menu.terminal'),
show: (row: Deployment) => row.engine === 'ollama' || row.engine === 'vllm',
disabled: (row: Deployment) =>
operationDisabled(row) || row.status !== 'Running' || !row.containerName || !isAdminOrNodeAdmin.value,
click: (row: Deployment) =>
@@ -682,44 +571,12 @@ const deploymentButtons = [
{
label: i18n.global.t('commons.button.sync'),
permission: true,
show: (row: Deployment) => row.engine === 'ollama' || row.engine === 'vllm',
disabled: operationDisabled,
click: (row: Deployment) => syncRef.value.open(row),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
disabled: (row: Deployment) => operationDisabled(row),
click: async (row: Deployment) => {
const node = currentNode.value;
const path = route.fullPath;
pending.value = `${row.engine}:${row.id}`;
try {
const [apps, resources] = await Promise.all([
readPages((page) => searchAppInstalled({ ...page, name: row.name, update: false }, node)),
appInstalledDeleteCheck(row.id, node),
]);
if (node !== currentNode.value || path !== route.fullPath) return;
const app = apps.find((item) => item.id === row.id && item.appKey === 'ollama');
if (!app) {
MsgError(i18n.global.t('commons.res.notFound'));
return;
}
if (resources.data?.length) {
appResourcesRef.value.acceptParams({ items: resources.data, key: app.appKey, installID: app.id });
} else {
await appDeleteRef.value.acceptParams(app);
}
} finally {
pending.value = '';
}
},
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: Deployment) => row.engine === 'vllm',
disabled: (row: Deployment) => operationDisabled(row),
click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
},
@@ -746,9 +603,17 @@ watch(
await router.replace('/ai/model/files');
return;
}
if (
isLocal &&
route.query.open === 'create' &&
route.query.engine !== 'vllm' &&
(route.query.engine === 'ollama' || route.query.tab === 'ollama')
) {
await router.replace({ name: 'AppAll', query: { install: 'ollama' } });
return;
}
if (isLocal || isFiles) {
apiType.value = '';
engine.value = '';
provider.value = '';
searchName.value = '';
resetPage();
@@ -761,15 +626,10 @@ watch(
}
await search();
if (cancelled) return;
if (
isCurrentPage.value &&
isLocal &&
route.query.open === 'create' &&
(route.query.engine === 'ollama' || route.query.engine === 'vllm' || route.query.tab === 'ollama')
) {
if (isCurrentPage.value && isLocal && route.query.open === 'create' && route.query.engine === 'vllm') {
await nextTick();
if (cancelled) return;
await createRef.value.open(route.query.engine === 'vllm' ? 'vllm' : 'ollama');
await createRef.value.open('vllm');
if (cancelled) return;
const query = { ...route.query };
delete query.open;
+1 -8
View File
@@ -1,20 +1,13 @@
<template>
<div>
<RouterButton :buttons="tabs" />
<RouterButton :buttons="[{ label: $t('aiTools.model.ollamaManagement'), path: '/ai/model/local/ollama' }]" />
<OllamaModels :key="currentNode" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import i18n from '@/lang';
import OllamaModels from './models/index.vue';
const { currentNode } = useGlobalStore();
const tabs = computed(() => [
{ label: i18n.global.t('aiTools.model.files'), path: '/ai/model/files' },
{ label: i18n.global.t('aiTools.model.services'), path: '/ai/model/account' },
{ label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' },
]);
</script>
@@ -1,16 +1,31 @@
<template>
<div v-loading="loading || initializing">
<LayoutContent back-name="LocalModel" :title="'Ollama ' + $t('aiTools.model.modelManagement')">
<LayoutContent :title="$t('aiTools.model.ollamaManagement')">
<template #app>
<AppStatus
ref="appStatusRef"
app-key="ollama"
:hide-setting="true"
v-model:loading="loading"
@is-exist="checkExist"
/>
</template>
<template #prompt>
<el-alert type="info" :closable="false" :title="$t('runtime.systemRestartHelper')" />
</template>
<template #leftToolBar>
<el-button v-permission :disabled="modelInfo.status !== 'Running'" type="primary" @click="onCreate">
{{ $t('commons.button.add') }}
</el-button>
<el-button v-permission :disabled="modelInfo.status !== 'Running'" @click="search">
{{ $t('database.loadFromRemote') }}
</el-button>
<el-button v-permission :disabled="modelInfo.status !== 'Running'" @click="bindDomain">
{{ $t('aiTools.proxy.proxy') }}
</el-button>
<el-button :disabled="modelInfo.status !== 'Running'" @click="onLoadConn">
{{ $t('database.databaseConnInfo') }}
</el-button>
<el-button :disabled="modelInfo.status !== 'Running'" icon="Position" @click="openWebUI">
OpenWebUI
</el-button>
<el-button v-permission :disabled="selects.length === 0" @click="onDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
@@ -29,7 +44,7 @@
<el-button
v-permission
type="primary"
@click="routerToNameWithQuery('LocalModel', { open: 'create', engine: 'ollama' })"
@click="routerToNameWithQuery('AppAll', { install: 'ollama' })"
>
{{ $t('commons.button.install') }}
</el-button>
@@ -126,6 +141,8 @@
</template>
</DrawerPro>
<BindDomain ref="bindDomainRef" />
<Connection ref="connectionRef" />
<PortJumpDialog ref="portRef" />
<TaskLog ref="taskLogRef" width="50%" @close="refresh" />
</div>
</template>
@@ -135,11 +152,16 @@ import { useSearchState } from '@/utils/search-reset';
import AddDialog from '@/views/ai/model/ollama/add/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import Terminal from '@/views/ai/model/ollama/terminal/index.vue';
import { toRef, onMounted, reactive, ref } from 'vue';
import { toRef, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import AppStatus from '@/components/app-status/index.vue';
import Connection from '@/views/ai/model/components/connection/index.vue';
import PortJumpDialog from '@/components/port-jump/index.vue';
import { App } from '@/api/interface/app';
import i18n from '@/lang';
import { deleteOllamaModel, loadOllamaModel, recreateOllamaModel, searchOllamaModel } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import { checkAppInstalled } from '@/api/modules/app';
import { getAppPort } from '@/api/modules/app';
import { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id';
import { MsgInfo, MsgSuccess } from '@/utils/message';
@@ -147,7 +169,10 @@ import BindDomain from '@/views/ai/model/ollama/domain/index.vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { routerToNameWithQuery } from '@/utils/router';
const { isMobile } = useGlobalStore();
const { isMobile, currentNode } = useGlobalStore();
const appStatusRef = ref();
const connectionRef = ref();
const portRef = ref();
const loading = ref(false);
const initializing = ref(true);
const loadFailed = ref(false);
@@ -177,7 +202,7 @@ const opRef = ref();
const operateIDs = ref();
const forceDelete = ref();
const modelInfo = reactive({ status: '' });
const modelInfo = reactive({ status: '', name: '', containerName: '', httpPort: 0 });
function selectable(row) {
return row.status !== 'Waiting';
@@ -327,27 +352,57 @@ const buttons = [
},
];
const onLoadConn = () => {
connectionRef.value.acceptParams({
name: modelInfo.name,
engine: 'ollama',
port: modelInfo.httpPort,
containerName: modelInfo.containerName,
appinstallID: appInstallID.value,
});
};
const openWebUI = async () => {
const node = currentNode.value;
const { data: port } = await getAppPort('ollama-webui', '');
if (node !== currentNode.value) return;
if (port) {
portRef.value.acceptParams({ port });
return;
}
const confirmed = await ElMessageBox.confirm(i18n.global.t('app.checkInstalledWarn', ['OpenWebUI']), 'OpenWebUI', {
confirmButtonText: i18n.global.t('database.goInstall'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
}).catch(() => false);
if (!confirmed || node !== currentNode.value) return;
await routerToNameWithQuery('AppAll', { install: 'ollama-webui' });
};
const checkExist = async (installed: App.CheckInstalled | false) => {
loadFailed.value = !installed;
selects.value = [];
if (!installed) {
modelInfo.status = '';
initializing.value = false;
return;
}
appInstallID.value = installed.appInstallId;
modelInstalled.value = installed.isExist;
modelInfo.status = installed.status;
modelInfo.name = installed.name;
modelInfo.containerName = installed.containerName;
modelInfo.httpPort = installed.httpPort;
if (installed.isExist) {
await search();
} else {
data.value = [];
paginationConfig.total = 0;
}
initializing.value = false;
};
const refresh = async () => {
initializing.value = true;
loadFailed.value = false;
selects.value = [];
try {
const { data: installed } = await checkAppInstalled('ollama', '');
appInstallID.value = installed.appInstallId;
modelInstalled.value = installed.isExist;
modelInfo.status = installed.status;
if (installed.isExist) {
await search();
} else {
data.value = [];
paginationConfig.total = 0;
}
} catch {
modelInfo.status = '';
loadFailed.value = true;
} finally {
initializing.value = false;
}
await appStatusRef.value?.onCheck('ollama', '');
};
onMounted(refresh);
</script>
@@ -1,154 +0,0 @@
<template>
<component
:is="inline ? 'div' : DrawerPro"
v-bind="inline ? {} : { modelValue: open, header: $t('commons.button.install'), size: 'large' }"
@update:model-value="open = $event"
@close="handleClose"
>
<AppInstallForm ref="formRef" v-model="form" :loading="loading" sectioned hide-gpu-config>
<template #after-params>
<GPUConfig ref="gpuRef" v-model="form.gpu" />
<details ref="syncSectionRef" open class="form-section">
<summary class="form-section-header">
<span>{{ $t('aiTools.model.syncModelAccount') }}</span>
<el-switch
@click.stop
@keydown.stop
v-model="form.syncModelAccount"
:aria-label="$t('aiTools.model.syncModelAccount')"
@change="(enabled) => enabled && syncSectionRef && (syncSectionRef.open = true)"
/>
</summary>
<div v-show="form.syncModelAccount" class="form-section-body">
<AccountAddress
ref="accountRef"
show-sync
hide-sync-toggle
:form="form"
:host-port="Number(form.params.PANEL_APP_PORT_HTTP) || 11434"
container-port="11434"
/>
</div>
</details>
</template>
</AppInstallForm>
<template v-if="!inline" #footer>
<el-button :disabled="loading" @click="open = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button
v-permission
type="primary"
:loading="loading"
:disabled="loading || gpuRef?.loading || !form.appDetailId"
@click="submit"
>
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</component>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { installApp } from '@/api/modules/app';
import { newUUID } from '@/utils/id';
import { MsgError } from '@/utils/message';
import i18n from '@/lang';
import DrawerPro from '@/components/drawer-pro/index.vue';
import AppInstallForm from '@/views/app-store/detail/form/index.vue';
import AccountAddress from '../../components/account-address/index.vue';
import { AI } from '@/api/interface/ai';
import GPUConfig from '../../components/gpu-config/index.vue';
const props = defineProps({ inline: Boolean });
const emit = defineEmits(['search', 'task', 'close']);
const open = ref(false);
const loading = ref(false);
const formRef = ref<InstanceType<typeof AppInstallForm>>();
const accountRef = ref();
const syncSectionRef = ref<HTMLDetailsElement>();
const gpuRef = ref<InstanceType<typeof GPUConfig>>();
const form = ref({
gpu: {
enabled: true,
mode: 'all',
deviceIDs: [],
capabilities: ['gpu', 'utility', 'compute'],
} as AI.ModelGPUConfig,
appDetailId: 0,
name: '',
params: {} as Record<string, any>,
containerName: '',
syncModelAccount: true,
modelAccountBaseURLType: 'container',
modelAccountBaseURL: '',
});
const handleClose = () => {
open.value = false;
formRef.value?.resetForm();
emit('close');
};
const openCreate = async () => {
open.value = true;
loading.value = true;
try {
await nextTick();
if (!open.value) return;
formRef.value.resetForm();
formRef.value.setFormData({
gpu: { enabled: true, mode: 'all', deviceIDs: [], capabilities: ['gpu', 'utility', 'compute'] },
restartPolicy: 'unless-stopped',
syncModelAccount: true,
modelAccountBaseURLType: 'container',
modelAccountBaseURL: '',
});
await formRef.value.initForm('ollama', true);
if (!open.value) return;
await gpuRef.value?.init(true);
await nextTick();
formRef.value?.clearValidate();
} finally {
loading.value = false;
}
};
const submit = async () => {
if (loading.value || gpuRef.value?.loading || !form.value.appDetailId || !(await formRef.value?.validate())) return;
const data = formRef.value.getFormData();
if (data.editCompose && !data.dockerCompose.trim()) {
MsgError(i18n.global.t('app.composeNullErr'));
return;
}
loading.value = true;
const taskID = newUUID();
try {
await installApp({
...data,
modelAccountBaseURL: accountRef.value.baseURL,
taskID,
});
emit('search');
emit('task', taskID);
open.value = false;
} finally {
loading.value = false;
}
};
watch(
() => form.value.name,
(name) => {
form.value.containerName = name;
},
);
watch(open, (value) => {
if (props.inline && !value) handleClose();
});
defineExpose({
openCreate,
submit,
loading,
disabled: computed(() => !form.value.appDetailId || gpuRef.value?.loading),
});
</script>
<style lang="scss" scoped>
@use '@/views/ai/model/components/form-section.scss';
</style>
+1 -1
View File
@@ -258,7 +258,7 @@ onMounted(async () => {
key: installKey.value,
},
};
if (!['ollama', 'vllm'].includes(installKey.value) || !jumpToInstall('', installKey.value)) {
if (installKey.value !== 'vllm' || !jumpToInstall('', installKey.value)) {
installRef.value.acceptParams(params);
}
}
+130 -11
View File
@@ -1,6 +1,6 @@
<template>
<div>
<LayoutContent :title="currentTitle">
<LayoutContent v-if="currentTab !== 'recipes'" :title="currentTitle">
<template #search>
<el-button
v-for="button in buttons"
@@ -58,6 +58,23 @@
<ProviderLabel :provider="familyProviders[family] || family" :display-name="family" />
</el-option>
</el-select>
<el-select
v-model="featuredWeightProvider"
clearable
filterable
placeholder=""
class="model-filter"
:aria-label="$t('aiTools.model.weightProvider')"
@change="handleFeaturedSearch"
>
<template #prefix>{{ $t('aiTools.model.weightProvider') }}</template>
<el-option
v-for="provider in featuredWeightProviders"
:key="provider"
:label="provider"
:value="provider"
/>
</el-select>
<TableSearch
v-model:searchName="featuredSearchName"
class="model-search"
@@ -116,20 +133,40 @@
:prop="field.prop"
:label="$t('aiTools.model.' + field.label)"
:min-width="field.width"
:card-type="field.prop === 'repoID' ? 'name' : 'content'"
:card-type="
field.prop === 'repoID' ? 'name' : field.prop === 'weightProvider' ? 'status' : 'content'
"
>
<template #default="{ row }">
<template v-if="field.prop === 'repoID'">
<span v-if="featuredViewMode === 'card'" class="featured-name-label">
{{ $t('aiTools.model.modelName') }}
</span>
<el-button link type="primary" class="featured-name" @click="openFeaturedModel(row)">
{{ row.repoID }}
<el-button
link
type="primary"
class="featured-name"
:title="row.repoID"
@click="openFeaturedModel(row)"
>
{{ row.repoID?.slice(row.repoID.indexOf('/') + 1) || '-' }}
</el-button>
<div v-if="featuredViewMode === 'card' && row.description" class="featured-description">
{{ row.description[language.toLowerCase()] || row.description.en }}
</div>
</template>
<el-tag
v-else-if="
field.prop === 'weightProvider' && featuredViewMode === 'card' && row.weightProvider
"
class="featured-provider"
type="info"
effect="plain"
size="small"
:title="$t('aiTools.model.weightProvider') + ': ' + row.weightProvider"
>
{{ row.weightProvider }}
</el-tag>
<ProviderLabel
v-else-if="field.prop === 'family'"
:provider="familyProviders[row.family]"
@@ -254,7 +291,7 @@
<template #default="{ row }">{{ row.localModel?.sizeFormatted || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" :label="$t('commons.table.date')" width="180" />
<el-table-column :label="$t('commons.table.operate')" width="140" fixed="right">
<el-table-column :label="$t('commons.table.operate')" width="240" fixed="right">
<template #default="{ row }">
<el-button v-permission v-if="row.canCancel" link type="primary" @click="cancelTask(row)">
{{ $t('commons.button.cancel') }}
@@ -271,6 +308,24 @@
>
{{ $t('aiTools.model.deploy') }}
</el-button>
<el-button
v-if="
recipesView &&
isProductPro &&
row.downloadType === 'all' &&
row.status === 'Success' &&
row.localModel
"
v-permission
link
type="primary"
@click="
recipeModelPath = row.localModel.path;
currentTab = 'recipes';
"
>
{{ $t('aiTools.model.createRecipe') }}
</el-button>
<el-button
v-permission
v-if="row.canRemoveRecord || (row.localModel && !row.canCancel)"
@@ -420,13 +475,29 @@
</ComplexTable>
</div>
</DrawerPro>
<component :is="recipesView" v-if="currentTab === 'recipes' && recipesView" :model-path="recipeModelPath">
<template #search>
<el-button
v-for="button in buttons"
:key="button.value"
class="tag-button"
:class="{ 'no-active': currentTab !== button.value }"
:type="currentTab === button.value ? 'primary' : undefined"
:aria-pressed="currentTab === button.value"
@click="currentTab = button.value"
>
{{ button.label }}
</el-button>
</template>
</component>
<Download ref="downloadRef" @search="handleDownloadQueued" />
<DownloadDelete ref="downloadDeleteRef" @search="loadDownloads" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch, type Component } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessageBox, FormInstance } from 'element-plus';
import { checkFile } from '@/api/modules/files';
import i18n from '@/lang';
@@ -455,10 +526,10 @@ import {
import { useGlobalStore } from '@/composables/useGlobalStore';
const emit = defineEmits<{ deploy: [modelDir: string] }>();
const { currentNode, isMobile, isOffline, language } = useGlobalStore();
const { currentNode, isMobile, isOffline, language, isProductPro } = useGlobalStore();
const downloadTypeFilter = ref<ModelDownloader.DownloadType | ''>('');
type DownloaderTab = 'featured' | 'downloader' | 'settings';
type DownloaderTab = 'featured' | 'downloader' | 'recipes' | 'settings';
type DownloaderSource = ModelDownloader.Source;
type SortValue = ModelDownloader.ModelSearch['sort'];
@@ -470,9 +541,17 @@ interface SortOption {
const searchCardLoading = ref(false);
let searchRequest = 0;
const savingSettings = ref(false);
const route = useRoute();
const router = useRouter();
const pagePath = route.path;
const recipeModules = import.meta.glob<{ default: Component }>('@/xpack/views/vllm/template/index.vue');
const recipeLoader = Object.values(recipeModules)[0];
const recipesView = recipeLoader ? defineAsyncComponent(recipeLoader) : undefined;
const recipeModelPath = ref('');
const currentTab = ref<DownloaderTab>('featured');
const featuredViewMode = ref<'table' | 'card'>('table');
const featuredFamily = ref('');
const featuredWeightProvider = ref('');
const featuredSearchName = ref('');
const familyProviders: Record<string, string> = {
Qwen: 'bailian',
@@ -484,12 +563,14 @@ const autoSync = ref<'Enable' | 'Disable'>();
const featuredModels = ref<ModelDownloader.FeaturedModel[]>([]);
const featuredColumns = ref<FuTableColumnConfig[]>([]);
const featuredFamilies = ref<string[]>([]);
const featuredWeightProviders = ref<string[]>([]);
const featuredPagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
const featuredLoading = ref(false);
let featuredRequest = 0;
const featuredFields = [
{ prop: 'repoID', label: 'modelName', width: 400 },
{ prop: 'family', label: 'modelCategory', width: 140 },
{ prop: 'weightProvider', label: 'weightProvider', width: 180 },
{ prop: 'architecture', label: 'modelArchitecture', width: 140 },
{ prop: 'backboneParams', label: 'backboneParams', width: 170 },
{ prop: 'activeParams', label: 'activeParams', width: 170 },
@@ -520,11 +601,14 @@ const openFeaturedModel = (row: ModelDownloader.FeaturedModel) => {
);
};
const buttons: Array<{ label: string; value: DownloaderTab }> = [
const buttons = computed<Array<{ label: string; value: DownloaderTab }>>(() => [
{ label: i18n.global.t('aiTools.model.featuredModels'), value: 'featured' },
{ label: i18n.global.t('aiTools.model.downloader'), value: 'downloader' },
...(recipesView && isProductPro.value
? [{ label: i18n.global.t('aiTools.model.recipes'), value: 'recipes' as const }]
: []),
{ label: i18n.global.t('commons.button.set'), value: 'settings' },
];
]);
const settings = reactive<ModelDownloader.Setting>({
modelDir: '/opt/1panel/ai/models',
@@ -556,7 +640,10 @@ const downloadPagination = reactive({
});
const currentTitle = computed(() => {
return buttons.find((item) => item.value === currentTab.value)?.label || i18n.global.t('aiTools.model.downloader');
return (
buttons.value.find((item) => item.value === currentTab.value)?.label ||
i18n.global.t('aiTools.model.downloader')
);
});
const huggingFaceSortOptions = computed<SortOption[]>(() => [
@@ -747,12 +834,14 @@ const loadFeaturedModels = async () => {
page: featuredPagination.currentPage,
pageSize: featuredPagination.pageSize,
family: featuredFamily.value,
weightProvider: featuredWeightProvider.value,
info: featuredSearchName.value,
});
if (node !== currentNode.value || request !== featuredRequest) return;
featuredModels.value = res.data.items;
featuredPagination.total = res.data.total;
featuredFamilies.value = res.data.families;
featuredWeightProviders.value = res.data.weightProviders;
} finally {
if (request === featuredRequest) featuredLoading.value = false;
}
@@ -839,15 +928,29 @@ const formatSizePair = (downloaded: number, total: number) => {
return `${formatBytes(downloaded)} / ${formatBytes(total)}`;
};
watch(
[() => route.query.tab, () => route.path, buttons],
([tab, path]) => {
if (path !== pagePath) return;
currentTab.value = buttons.value.some((button) => button.value === tab) ? (tab as DownloaderTab) : 'featured';
},
{ immediate: true },
);
watch(currentTab, async (value) => {
if (value !== 'recipes') recipeModelPath.value = '';
if (route.path === pagePath && route.query.tab !== value)
await router.replace({ query: { ...route.query, tab: value } });
if (value !== 'settings') await refreshCurrent();
});
watch(currentNode, async () => {
recipeModelPath.value = '';
featuredRequest++;
featuredLoading.value = false;
featuredModels.value = [];
featuredFamilies.value = [];
featuredWeightProviders.value = [];
featuredPagination.currentPage = 1;
featuredPagination.total = 0;
autoSync.value = undefined;
@@ -904,6 +1007,14 @@ onMounted(() => {
min-height: 0;
}
:deep(.complex-table__card-header) {
align-items: flex-start;
}
:deep(.complex-table__card-status) {
max-width: 40%;
}
:deep(.complex-table__card-value) {
white-space: normal;
overflow-wrap: anywhere;
@@ -915,6 +1026,14 @@ onMounted(() => {
}
}
.featured-provider {
max-width: 100%;
height: auto;
min-height: 20px;
white-space: normal;
overflow-wrap: anywhere;
}
.featured-name-label {
display: block;
margin-bottom: 6px;
@@ -54,6 +54,11 @@
{{ $t('nginx.brotliManagedExternallyHelper') }}
</el-alert>
</el-col>
<el-col :span="24" v-if="brotliManagedPending">
<el-alert type="info" :closable="false" class="mb-2">
{{ $t('nginx.brotliManagedPendingHelper') }}
</el-alert>
</el-col>
<el-col :span="24" v-if="brotliManagedUnavailable">
<el-alert type="warning" :closable="false" class="mb-2">
{{ $t('nginx.brotliManagedUnavailableHelper') }}
@@ -127,6 +132,8 @@ let loading = ref(false);
// disappear together with the module.
const brotliAvailable = ref(false);
const brotliManagedExternally = ref(false);
// Pending: nginx does not load the managed file yet, and saving wires it in.
const brotliManagedPending = ref(false);
const brotliManagedUnavailable = ref(false);
const variablesRules = reactive({
@@ -201,6 +208,7 @@ const getBrotliParams = async () => {
}
const res = await getNginxConfigByScope<Nginx.NginxBrotliRes>({ scope: 'brotli' });
brotliManagedExternally.value = !!res.data?.managedExternally;
brotliManagedPending.value = !!res.data?.managedPending;
brotliManagedUnavailable.value = !!res.data?.managedUnavailable;
for (const param of res.data?.params ?? []) {
if (param.params.length === 0) {