feat(ai): unify model services and inference engine management (#14175)

This commit is contained in:
ssongliu
2026-10-23 18:39:22 +08:00
committed by GitHub
parent 9295f4ef8a
commit c4b76a8e89
62 changed files with 3033 additions and 856 deletions
+1
View File
@@ -386,6 +386,7 @@ type AgentAccountProviderCountReq struct {
}
type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"`
+2 -1
View File
@@ -171,7 +171,8 @@ type ContainerItemStats struct {
BuildCacheReclaimable int64 `json:"buildCacheReclaimable"`
}
type ContainerListStats struct {
ContainerID string `json:"containerID"`
ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"`
+3
View File
@@ -23,6 +23,9 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"`
TaskID string `json:"taskID"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
AppContainerConfig
NodePushConfig
}
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct {
BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
+21
View File
@@ -4,6 +4,7 @@ import (
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
}
func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil
}
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
if len(models) == 0 {
return nil
}
for i := range models {
models[i].AccountID = account.ID
models[i].SortOrder = i + 1
}
return tx.Create(&models).Error
})
}
func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers))
+45 -7
View File
@@ -1055,7 +1055,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
if err != nil {
return err
}
@@ -1108,12 +1108,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel
}
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil {
return err
}
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability)
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
if err != nil {
return err
}
@@ -1127,7 +1130,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark
account.Verified = true
if err := global.DB.Save(account).Error; err != nil {
if err := agentAccountRepo.Save(account); err != nil {
return err
}
terminalai.InvalidateTerminalRuntimeCache()
@@ -1158,13 +1161,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil {
return 0, nil, err
}
installIDs := make([]uint, 0, len(list))
for _, account := range list {
if account.AppInstallID != 0 {
installIDs = append(installIDs, account.AppInstallID)
}
}
localInstalls := make(map[uint]bool, len(installIDs))
if len(installIDs) > 0 {
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
if err != nil {
return 0, nil, err
}
for _, install := range installs {
localInstalls[install.ID] = true
}
}
items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list {
apiKey := ""
if item.RememberAPIKey {
apiKey = item.APIKey
}
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID,
MasterAccountID: item.MasterAccountID,
Provider: item.Provider,
@@ -1362,13 +1386,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true)
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
return err
}
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
+171
View File
@@ -0,0 +1,171 @@
package service
import (
"errors"
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"gorm.io/gorm"
)
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
return nil, fmt.Errorf("invalid local model installation")
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
return nil, fmt.Errorf("model account belongs to another installation")
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if !createIfMissing {
return nil, nil
}
name := install.Name
for suffix := 1; ; suffix++ {
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
if err != nil {
return nil, err
}
if len(existing) == 0 {
break
}
name = fmt.Sprintf("%s-%d", install.Name, suffix)
}
apiType, apiKey := "openai-completions", "vllm-local"
if install.App.Key == "ollama" {
apiType, apiKey = "openai-responses", "ollama-local"
}
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
}
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
account.AppInstallID = install.ID
if updateBaseURL {
account.BaseURL = baseURL
}
rows := make([]model.AgentAccountModel, 0, len(models))
verifyModelExists := account.VerifyModel == ""
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
if item.ID == account.VerifyModel {
verifyModelExists = true
}
}
if !verifyModelExists {
account.VerifyModel = ""
accountChanged = true
}
var previous []model.AgentAccountModel
if account.ID != 0 {
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
}
modelsChanged := len(previous) != len(rows)
if !modelsChanged {
for i := range rows {
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
modelsChanged = true
break
}
}
}
if modelsChanged {
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
return account, nil
}
if err != nil {
return nil, err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return account, nil
}
func SyncOllamaModelAccount(createIfMissing bool) error {
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
if err != nil {
return err
}
items := make([]dto.AgentAccountModel, 0, len(models))
for _, item := range models {
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
}
_, err = SyncLocalModelAccount(install, 0, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), items, false, createIfMissing)
return err
}
func CleanupLocalModelAccount(installID uint) {
if installID == 0 {
return
}
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
if err != nil {
global.LOG.Errorf("load local model accounts: %v", err)
return
}
for _, account := range accounts {
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
}
}
}
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
switch addressType {
case "custom":
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return "", fmt.Errorf("model account base URL must start with http:// or https://")
}
return baseURL, nil
case "localhost":
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
case "systemIP":
systemIP, err := settingRepo.GetValueByKey("SystemIP")
if err != nil {
return "", err
}
systemIP = strings.TrimSpace(systemIP)
if systemIP == "" {
systemIP = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
default:
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
+2 -2
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil
}
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) {
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err
}
modelID = strings.TrimSpace(modelID)
if modelID == "" {
if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
}
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
+8 -2
View File
@@ -125,6 +125,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -179,6 +182,9 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
}
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}()
return nil
}
@@ -202,7 +208,7 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem)
}
return nil
return SyncOllamaModelAccount(false)
}
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
@@ -250,7 +256,7 @@ func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) {
_ = aiRepo.Create(&item)
}
return dropList, nil
return dropList, SyncOllamaModelAccount(false)
}
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
+12
View File
@@ -499,6 +499,14 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName
appInstall.ContainerName = containerName
modelAccountBaseURL := ""
if app.Key == "ollama" {
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
if err != nil {
return
}
}
index := 0
serviceName := ""
for k := range servicesMap {
@@ -609,6 +617,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err
}
updateToolApp(appInstall)
if app.Key == "ollama" {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil
}
+6 -1
View File
@@ -469,7 +469,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir)
}
tx.Commit()
if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 {
+5 -3
View File
@@ -324,10 +324,12 @@ func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string)
defer wg.Done()
for index := range jobs {
datas[index].ContainerID = list[index].ID
if ctx.Err() != nil || list[index].State != "running" {
continue
if ctx.Err() == nil && list[index].State == "running" {
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
}
if len(list[index].Names) > 0 {
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
}
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
}
}()
}
+1
View File
@@ -117,6 +117,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddVLLMSampleInterval,
migrations.AddNativeAgentInstalls,
migrations.AddQuickDirectory,
migrations.AddLocalModelAccountLink,
}
}
+10
View File
@@ -1895,3 +1895,13 @@ var AddQuickDirectory = &gormigrate.Migration{
return tx.AutoMigrate(&model.QuickDirectory{})
},
}
var AddLocalModelAccountLink = &gormigrate.Migration{
ID: "20261023-add-local-model-account-link",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.AgentAccount{}); err != nil {
return err
}
return service.SyncOllamaModelAccount(true)
},
}
+1
View File
@@ -73,6 +73,7 @@ var WebUrlMap = map[string]struct{}{
"/ai/benchmark": {},
"/ai/model/account": {},
"/ai/model/local": {},
"/ai/model/files": {},
"/ai/gpu": {},
"/ai/gpu/current": {},
"/ai/gpu/history": {},
+11 -11
View File
@@ -98,8 +98,8 @@ func LoadMenus() string {
{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,
Children: []dto.ShowMenu{
{ID: "44", Disabled: false, Title: "aiTools.agents.agent", IsShow: true, Label: "Agents", Path: "/ai/agents/agent", Sort: 50},
{ID: "41", Disabled: false, Title: "aiTools.model.model", IsShow: true, Label: "AIModel", Path: "/ai/model/account", Sort: 100},
{ID: "41", Disabled: false, Title: "aiTools.model.model", IsShow: true, Label: "AIModel", Path: "/ai/model/account", 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},
}},
@@ -149,7 +149,7 @@ func LoadMenus() string {
IsShow: true,
Label: "AIProxyManagement",
Path: "/ai/ai-proxy/model-pool",
Sort: 150,
Sort: 75,
}, "AIModel")
item[i].Children = UpsertMenuByLabel(item[i].Children, dto.ShowMenu{
ID: "47",
@@ -158,8 +158,8 @@ func LoadMenus() string {
IsShow: true,
Label: "SkillsHub",
Path: "/ai/skills-hub",
Sort: 155,
}, "AIProxyManagement")
Sort: 225,
}, "MCPServer")
item[i].Children = UpsertMenuByLabel(item[i].Children, dto.ShowMenu{
ID: "45",
Disabled: false,
@@ -167,7 +167,7 @@ func LoadMenus() string {
IsShow: true,
Label: "AIBenchmark",
Path: "/ai/benchmark",
Sort: 160,
Sort: 250,
}, "SkillsHub")
continue
}
@@ -204,12 +204,12 @@ func MenuSort() []dto.MenuLabelSort {
{Label: "Home-Menu", Sort: 100},
{Label: "App-Menu", Sort: 200},
{Label: "AI-Menu", Sort: 300},
{Label: "Agents", Sort: 50},
{Label: "AIModel", Sort: 100},
{Label: "AIProxyManagement", Sort: 150},
{Label: "SkillsHub", Sort: 155},
{Label: "AIBenchmark", Sort: 160},
{Label: "AIModel", Sort: 50},
{Label: "AIProxyManagement", Sort: 75},
{Label: "Agents", Sort: 100},
{Label: "MCPServer", Sort: 200},
{Label: "SkillsHub", Sort: 225},
{Label: "AIBenchmark", Sort: 250},
{Label: "GPU", Sort: 300},
{Label: "Website-Menu", Sort: 400},
{Label: "Website", Sort: 100},
+1
View File
@@ -69,5 +69,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.MoveVirtualMachineMenuToXpack,
migrations.AddAPIKeys,
migrations.AddOperationLogAPIKey,
migrations.UpdateAiMenuOrder,
}
}
+15
View File
@@ -1376,6 +1376,21 @@ var RemoveUpageHideMenu = &gormigrate.Migration{
Migrate: reconcileHideMenuSetting,
}
var UpdateAiMenuOrder = &gormigrate.Migration{
ID: "20261023-update-ai-menu-order",
Migrate: func(tx *gorm.DB) error {
return helper.UpdateChildMenuSortByLabel(tx, "AI-Menu", []dto.MenuLabelSort{
{Label: "AIModel", Sort: 50},
{Label: "AIProxyManagement", Sort: 75},
{Label: "Agents", Sort: 100},
{Label: "MCPServer", Sort: 200},
{Label: "SkillsHub", Sort: 225},
{Label: "AIBenchmark", Sort: 250},
{Label: "GPU", Sort: 300},
})
},
}
var UpdateFirewallMenuPath = &gormigrate.Migration{
ID: "20260819-update-firewall-menu-path",
Migrate: func(tx *gorm.DB) error {
+2 -1
View File
@@ -18,7 +18,7 @@ export namespace AI {
name: string;
}
export interface OllamaModelSearch extends ReqPage {
info: string;
info?: string;
}
export interface Info {
@@ -738,6 +738,7 @@ export namespace AI {
}
export interface AgentAccountItem {
appInstallId?: number;
id: number;
provider: string;
providerName: string;
+2
View File
@@ -136,6 +136,8 @@ export namespace App {
}
export interface AppInstall {
modelAccountBaseURLType?: string;
modelAccountBaseURL?: string;
appDetailId: number;
params: any;
taskID: string;
+1
View File
@@ -177,6 +177,7 @@ export namespace Container {
}
export interface ContainerListStats {
containerID: string;
containerName: string;
cpuTotalUsage: number;
systemUsage: number;
cpuPercent: number;
+7 -2
View File
@@ -22,8 +22,13 @@ export const loadOllamaModel = (name: string) => {
export const syncOllamaModel = () => {
return http.post<Array<AI.OllamaModelDropInfo>>(`/ai/ollama/model/sync`);
};
export const closeOllamaModel = (name: string) => {
return http.post(`/ai/ollama/close`, { name: name });
export const closeOllamaModel = (name: string, currentNode?: string) => {
return http.post(
`/ai/ollama/close`,
{ name: name },
undefined,
currentNode ? { CurrentNode: currentNode } : undefined,
);
};
export const bindDomain = (req: AI.BindDomain) => {
+7 -3
View File
@@ -90,10 +90,14 @@ export const containerItemStats = (containerID: string, currentNode?: string) =>
currentNode ? { CurrentNode: currentNode } : undefined,
);
};
export const containerListStats = (currentNode?: string, ids?: string[], signal?: AbortSignal) => {
export const containerListStats = (
currentNode?: string,
ids?: string[],
config?: AxiosRequestConfig & { skipErrorMessage?: boolean },
) => {
return http.get<Array<Container.ContainerListStats>>(`/containers/list/stats`, ids ? { ids: ids.join(',') } : {}, {
headers: currentNode ? { CurrentNode: currentNode } : undefined,
signal,
...config,
headers: { ...config?.headers, ...(currentNode ? { CurrentNode: currentNode } : {}) },
});
};
export const containerStats = (id: string) => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,51 @@
<template>
<div>
<div class="source-font">CPU: {{ stats.cpuPercent.toFixed(2) }}%</div>
<div class="float-left source-font">{{ $t('monitor.memory') }}: {{ stats.memoryPercent.toFixed(2) }}%</div>
<el-popover placement="right" width="500px" class="float-right">
<template #reference>
<SvgIcon iconName="p-xiangqing" className="resource-detail-icon" />
</template>
<template #default>
<el-descriptions direction="vertical" border :column="3" size="small">
<el-descriptions-item :label="$t('container.cpuUsage')">
{{ computeCPU(stats.cpuTotalUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.cpuTotal')">
{{ computeCPU(stats.systemUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.core')">{{ stats.percpuUsage }}</el-descriptions-item>
<el-descriptions-item :label="$t('container.memUsage')">
{{ computeSizeForDocker(stats.memoryUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memCache')">
{{ computeSizeForDocker(stats.memoryCache) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memTotal')">
{{ computeSizeForDocker(stats.memoryLimit) }}
</el-descriptions-item>
<slot />
</el-descriptions>
<slot name="footer" />
</template>
</el-popover>
</div>
</template>
<script setup lang="ts">
import type { Container } from '@/api/interface/container';
import { computeCPU, computeSizeForDocker } from '@/utils/size';
import SvgIcon from '@/components/svg-icon/svg-icon.vue';
defineProps<{ stats: Container.ContainerListStats }>();
</script>
<style scoped lang="scss">
.source-font {
font-size: 12px;
}
:deep(.resource-detail-icon) {
margin-top: -3px;
font-size: 6px;
cursor: pointer;
}
</style>
+31 -2
View File
@@ -1017,6 +1017,37 @@ const message = {
validateImageAvailabilityHelper: 'Generates one minimal image before saving and may incur charges',
},
model: {
modelCategory: 'Model category',
modelName: 'Model name',
modelArchitecture: 'Model type',
backboneParams: 'MoE backbone parameters',
activeParams: 'Active parameters',
engram: 'Engram',
weightFootprint: 'Weight size',
undisclosed: 'Undisclosed',
featuredModels: 'Featured models',
deploy: 'Deploy',
approxWeightSize: 'Approx. {0} GB',
services: 'Model services',
addService: 'Add model service',
addEngine: 'Add inference engine',
onlineModel: 'Online models',
instanceDeployment: 'Deploy by instance',
nvidiaDeployment: 'NVIDIA-optimized deployment',
engines: 'Inference engines',
modelManagement: 'Model management',
serviceDetails: 'Service details',
deployments: 'Local deployments',
files: 'Model files',
connectExternal: 'Connect external service',
deploymentTemplates: 'Startup command templates',
engineProvider: 'Engine / Provider',
endpoint: 'Endpoint',
cloudAccount: 'Cloud account',
connectionStatus: 'Connection status',
unverified: 'Unverified',
manage: 'Manage',
loadFailed: 'Failed to load. Refresh to retry.',
model: 'Models',
localModel: 'Local Models',
create_helper: 'Pull "{0}"',
@@ -1032,7 +1063,6 @@ const message = {
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 account',
modelAccountAddressHelper:
'Container address is for agent containers; 127.0.0.1, system IP, or custom address is for AI Gateway or external services.',
containerAddress: 'Container address',
@@ -1055,7 +1085,6 @@ const message = {
sortCreated: 'Newest',
downloadsCount: 'Downloads',
likes: 'Likes',
taskQueue: 'Download Queue',
progress: 'Progress',
localDownloaded: 'Downloaded',
modelInfo: 'Model Details',
+31 -2
View File
@@ -1019,6 +1019,37 @@ const message = {
validateImageAvailabilityHelper: 'Genera una imagen mínima antes de guardar y puede generar costes',
},
model: {
modelCategory: 'Categoría del modelo',
modelName: 'Nombre del modelo',
modelArchitecture: 'Tipo de modelo',
backboneParams: 'Parámetros base de MoE',
activeParams: 'Parámetros activos',
engram: 'Engram',
weightFootprint: 'Tamaño de los pesos',
undisclosed: 'No divulgado',
featuredModels: 'Modelos destacados',
deploy: 'Desplegar',
approxWeightSize: 'Aprox. {0} GB',
services: 'Servicios de modelos',
addService: 'Añadir servicio de modelo',
addEngine: 'Añadir motor de inferencia',
onlineModel: 'Modelos en línea',
instanceDeployment: 'Despliegue por instancia',
nvidiaDeployment: 'Despliegue optimizado para NVIDIA',
engines: 'Motores de inferencia',
modelManagement: 'Gestión de modelos',
serviceDetails: 'Detalles del servicio',
deployments: 'Despliegues locales',
files: 'Archivos de modelos',
connectExternal: 'Conectar servicio externo',
deploymentTemplates: 'Plantillas de comandos de inicio',
engineProvider: 'Motor / Proveedor',
endpoint: 'Punto de conexión',
cloudAccount: 'Cuenta en la nube',
connectionStatus: 'Estado de conexión',
unverified: 'Sin verificar',
manage: 'Gestionar',
loadFailed: 'No se pudo cargar. Actualice la página e inténtelo de nuevo.',
model: 'Modelo',
localModel: 'Modelos locales',
create_helper: 'Descargar "{0}"',
@@ -1035,7 +1066,6 @@ const message = {
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 cuenta de modelo',
modelAccountAddressHelper:
'La dirección del contenedor es para contenedores de agentes; 127.0.0.1, IP del sistema o dirección personalizada es para AI Gateway o servicios externos.',
containerAddress: 'Dirección del contenedor',
@@ -1058,7 +1088,6 @@ const message = {
sortCreated: 'Más recientes',
downloadsCount: 'Descargas',
likes: 'Me gusta',
taskQueue: 'Cola de descargas',
progress: 'Progreso',
localDownloaded: 'Descargados',
modelInfo: 'Detalles del modelo',
+31 -2
View File
@@ -1002,6 +1002,37 @@ const message = {
validateImageAvailabilityHelper: 'پیش از ذخیره یک تصویر حداقلی تولید می‌شود و ممکن است هزینه داشته باشد',
},
model: {
modelCategory: 'دسته‌بندی مدل',
modelName: 'نام مدل',
modelArchitecture: 'نوع مدل',
backboneParams: 'پارامترهای پایه MoE',
activeParams: 'پارامترهای فعال',
engram: 'Engram',
weightFootprint: 'حجم وزن‌ها',
undisclosed: 'اعلام نشده',
featuredModels: 'مدل‌های برگزیده',
deploy: 'استقرار',
approxWeightSize: 'حدود {0} گیگابایت',
services: 'سرویس‌های مدل',
addService: 'افزودن سرویس مدل',
addEngine: 'افزودن موتور استنتاج',
onlineModel: 'مدل‌های آنلاین',
instanceDeployment: 'استقرار بر اساس نمونه',
nvidiaDeployment: 'استقرار بهینه‌شده برای NVIDIA',
engines: 'موتورهای استنتاج',
modelManagement: 'مدیریت مدل‌ها',
serviceDetails: 'جزئیات سرویس',
deployments: 'استقرارهای محلی',
files: 'فایل‌های مدل',
connectExternal: 'اتصال به سرویس خارجی',
deploymentTemplates: 'الگوهای فرمان راه‌اندازی',
engineProvider: 'موتور / ارائه‌دهنده',
endpoint: 'نقطه پایانی',
cloudAccount: 'حساب ابری',
connectionStatus: 'وضعیت اتصال',
unverified: 'تأیید نشده',
manage: 'مدیریت',
loadFailed: 'بارگذاری ناموفق بود. صفحه را تازه‌سازی کرده و دوباره تلاش کنید.',
model: 'مدل‌ها',
localModel: 'مدل‌های محلی',
create_helper: 'دریافت "{0}"',
@@ -1017,7 +1048,6 @@ const message = {
vllmCommandPortHelper:
'فرمان راه‌اندازی باید از پورت {0} استفاده کند؛ در غیر این صورت سرویس قابل دسترسی نخواهد بود.',
ascendVisibleDevices: 'دستگاه‌های قابل مشاهده Ascend (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'همگام‌سازی با حساب مدل',
modelAccountAddressHelper:
'آدرس کانتینر برای کانتینرهای عامل است؛ 127.0.0.1، IP سیستم یا آدرس سفارشی برای دروازه AI یا خدمات خارجی است.',
containerAddress: 'آدرس کانتینر',
@@ -1040,7 +1070,6 @@ const message = {
sortCreated: 'جدیدترین',
downloadsCount: 'دانلودها',
likes: 'لایک‌ها',
taskQueue: 'صف دانلود',
progress: 'پیشرفت',
localDownloaded: 'دانلود شده',
modelInfo: 'جزئیات مدل',
+31 -2
View File
@@ -1006,6 +1006,37 @@ const message = {
validateImageAvailabilityHelper: '保存前に最小画像を1枚生成するため、料金が発生する場合があります',
},
model: {
modelCategory: 'モデルカテゴリ',
modelName: 'モデル名',
modelArchitecture: 'モデルの種類',
backboneParams: 'MoE バックボーンパラメータ数',
activeParams: 'アクティブパラメータ数',
engram: 'Engram',
weightFootprint: '重みのサイズ',
undisclosed: '非公開',
featuredModels: '注目のモデル',
deploy: 'デプロイ',
approxWeightSize: '約 {0} GB',
services: 'モデルサービス',
addService: 'モデルサービスを追加',
addEngine: '推論エンジンを追加',
onlineModel: 'オンラインモデル',
instanceDeployment: 'インスタンス単位でデプロイ',
nvidiaDeployment: 'NVIDIA 向け最適化デプロイ',
engines: '推論エンジン',
modelManagement: 'モデル管理',
serviceDetails: 'サービス詳細',
deployments: 'ローカルデプロイ',
files: 'モデルファイル',
connectExternal: '外部サービスに接続',
deploymentTemplates: '起動コマンドテンプレート',
engineProvider: 'エンジン / プロバイダー',
endpoint: 'エンドポイント',
cloudAccount: 'クラウドアカウント',
connectionStatus: '接続状態',
unverified: '未検証',
manage: '管理',
loadFailed: '読み込みに失敗しました。更新して再試行してください。',
model: 'モデル',
localModel: 'ローカルモデル',
create_helper: 'を取得 "{0}"',
@@ -1021,7 +1052,6 @@ const message = {
vllmCommandPortHelper:
'起動コマンドではポート {0} を使用する必要があります。使用しない場合、サービスにアクセスできません。',
ascendVisibleDevices: 'Ascend 可視デバイス(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'モデルアカウントに同期',
modelAccountAddressHelper:
'コンテナアドレスはエージェントコンテナ向けです。127.0.0.1、システム IP、カスタムアドレスは AI Gateway または外部サービス向けです。',
containerAddress: 'コンテナアドレス',
@@ -1044,7 +1074,6 @@ const message = {
sortCreated: '新規作成',
downloadsCount: 'ダウンロード数',
likes: 'いいね',
taskQueue: 'ダウンロードキュー',
progress: '進捗',
localDownloaded: 'ダウンロード済み',
modelInfo: 'モデル詳細',
+31 -2
View File
@@ -996,6 +996,37 @@ const message = {
validateImageAvailabilityHelper: '저장 전에 최소 이미지 한 장을 생성하며 비용이 발생할 수 있습니다',
},
model: {
modelCategory: '모델 카테고리',
modelName: '모델 이름',
modelArchitecture: '모델 유형',
backboneParams: 'MoE 백본 매개변수 수',
activeParams: '활성 매개변수 수',
engram: 'Engram',
weightFootprint: '가중치 크기',
undisclosed: '비공개',
featuredModels: '추천 모델',
deploy: '배포',
approxWeightSize: '약 {0} GB',
services: '모델 서비스',
addService: '모델 서비스 추가',
addEngine: '추론 엔진 추가',
onlineModel: '온라인 모델',
instanceDeployment: '인스턴스별 배포',
nvidiaDeployment: 'NVIDIA 최적화 배포',
engines: '추론 엔진',
modelManagement: '모델 관리',
serviceDetails: '서비스 상세 정보',
deployments: '로컬 배포',
files: '모델 파일',
connectExternal: '외부 서비스 연결',
deploymentTemplates: '시작 명령 템플릿',
engineProvider: '엔진 / 공급자',
endpoint: '엔드포인트',
cloudAccount: '클라우드 계정',
connectionStatus: '연결 상태',
unverified: '미검증',
manage: '관리',
loadFailed: '불러오지 못했습니다. 새로 고침 후 다시 시도하세요.',
model: '모델',
localModel: '로컬 모델',
create_helper: '가져오기 "{0}"',
@@ -1010,7 +1041,6 @@ const message = {
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '시작 명령은 {0} 포트를 사용해야 하며, 그렇지 않으면 서비스에 접근할 수 없습니다.',
ascendVisibleDevices: 'Ascend 표시 장치 (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '모델 계정에 동기화',
modelAccountAddressHelper:
'컨테이너 주소는 에이전트 컨테이너 호출에 적합합니다. 127.0.0.1, 시스템 IP 또는 사용자 지정 주소는 AI Gateway 또는 외부 서비스 호출에 적합합니다.',
containerAddress: '컨테이너 주소',
@@ -1033,7 +1063,6 @@ const message = {
sortCreated: '최신 생성',
downloadsCount: '다운로드 수',
likes: '좋아요',
taskQueue: '다운로드 대기열',
progress: '진행률',
localDownloaded: '다운로드됨',
modelInfo: '모델 상세 정보',
+31 -2
View File
@@ -993,6 +993,37 @@ const message = {
validateImageAvailabilityHelper: 'ສ້າງຮູບຂະໜາດນ້ອຍໜຶ່ງຮູບກ່ອນບັນທຶກ ແລະ ອາດມີຄ່າໃຊ້ຈ່າຍ',
},
model: {
modelCategory: 'ໝວດໝູ່ໂມເດວ',
modelName: 'ຊື່ໂມເດວ',
modelArchitecture: 'ປະເພດໂມເດວ',
backboneParams: 'ພາຣາມິເຕີຫຼັກຂອງ MoE',
activeParams: 'ພາຣາມິເຕີທີ່ໃຊ້ງານ',
engram: 'Engram',
weightFootprint: 'ຂະໜາດນ້ຳໜັກ',
undisclosed: 'ບໍ່ເປີດເຜີຍ',
featuredModels: 'ໂມເດວແນະນຳ',
deploy: 'ນຳໃຊ້',
approxWeightSize: 'ປະມານ {0} GB',
services: 'ບໍລິການໂມເດວ',
addService: 'ເພີ່ມບໍລິການໂມເດວ',
addEngine: 'ເພີ່ມເຄື່ອງຈັກອະນຸມານ',
onlineModel: 'ໂມເດວອອນລາຍ',
instanceDeployment: 'ນຳໃຊ້ຕາມອິນສະແຕນ',
nvidiaDeployment: 'ການນຳໃຊ້ທີ່ປັບໃຫ້ເໝາະກັບ NVIDIA',
engines: 'ເຄື່ອງຈັກອະນຸມານ',
modelManagement: 'ຈັດການໂມເດວ',
serviceDetails: 'ລາຍລະອຽດບໍລິການ',
deployments: 'ການນຳໃຊ້ພາຍໃນເຄື່ອງ',
files: 'ໄຟລ໌ໂມເດວ',
connectExternal: 'ເຊື່ອມຕໍ່ບໍລິການພາຍນອກ',
deploymentTemplates: 'ແມ່ແບບຄຳສັ່ງເລີ່ມຕົ້ນ',
engineProvider: 'ເຄື່ອງຈັກ / ຜູ້ໃຫ້ບໍລິການ',
endpoint: 'ຈຸດເຊື່ອມຕໍ່',
cloudAccount: 'ບັນຊີຄລາວ',
connectionStatus: 'ສະຖານະການເຊື່ອມຕໍ່',
unverified: 'ຍັງບໍ່ໄດ້ຢືນຢັນ',
manage: 'ຈັດການ',
loadFailed: 'ໂຫຼດບໍ່ສຳເລັດ. ກະລຸນາໂຫຼດໜ້າຄືນແລ້ວລອງອີກຄັ້ງ.',
model: 'ໂມເດວ',
localModel: 'ໂມເດວໃນເຄື່ອງ',
create_helper: 'ດຶງ (Pull) "{0}"',
@@ -1007,7 +1038,6 @@ const message = {
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: 'ຄຳສັ່ງເລີ່ມຕົ້ນຕ້ອງໃຊ້ພອດ {0}; ບໍ່ດັ່ງນັ້ນຈະບໍ່ສາມາດເຂົ້າເຖິງບໍລິການໄດ້.',
ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້ (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'ຊິ້ງຄ໌ໄປຍັງບັນຊີໂມເດວ',
modelAccountAddressHelper:
'ທີ່ຢູ່ຄອນເທນເນີແມ່ນສຳລັບຄອນເທນເນີຕົວແທນ; 127.0.0.1, IP ລະບົບ, ຫຼື ທີ່ຢູ່ກຳນົດເອງແມ່ນສຳລັບ AI Gateway ຫຼື ບໍລິການພາຍນອກ.',
containerAddress: 'ທີ່ຢູ່ຄອນເທນເນີ',
@@ -1030,7 +1060,6 @@ const message = {
sortCreated: 'ໃໝ່ລ່າສຸດ',
downloadsCount: 'ດາວໂຫຼດ',
likes: 'ຖືກໃຈ',
taskQueue: 'ຄິວດາວໂຫຼດ',
progress: 'ຄວາມຄືບໜ້າ',
localDownloaded: 'ດາວໂຫຼດແລ້ວ',
modelInfo: 'ລາຍລະອຽດໂມເດວ',
+31 -2
View File
@@ -1028,6 +1028,37 @@ const message = {
validateImageAvailabilityHelper: 'Menjana satu imej minimum sebelum menyimpan dan mungkin dikenakan caj',
},
model: {
modelCategory: 'Kategori model',
modelName: 'Nama model',
modelArchitecture: 'Jenis model',
backboneParams: 'Parameter teras MoE',
activeParams: 'Parameter aktif',
engram: 'Engram',
weightFootprint: 'Saiz pemberat',
undisclosed: 'Tidak didedahkan',
featuredModels: 'Model pilihan',
deploy: 'Kerahkan',
approxWeightSize: 'Kira-kira {0} GB',
services: 'Perkhidmatan model',
addService: 'Tambah perkhidmatan model',
addEngine: 'Tambah enjin inferens',
onlineModel: 'Model dalam talian',
instanceDeployment: 'Pengerahan mengikut instans',
nvidiaDeployment: 'Pengerahan dioptimumkan untuk NVIDIA',
engines: 'Enjin inferens',
modelManagement: 'Pengurusan model',
serviceDetails: 'Butiran perkhidmatan',
deployments: 'Pengerahan setempat',
files: 'Fail model',
connectExternal: 'Sambungkan perkhidmatan luaran',
deploymentTemplates: 'Templat arahan permulaan',
engineProvider: 'Enjin / Pembekal',
endpoint: 'Titik akhir',
cloudAccount: 'Akaun awan',
connectionStatus: 'Status sambungan',
unverified: 'Belum disahkan',
manage: 'Urus',
loadFailed: 'Gagal memuatkan. Muat semula dan cuba lagi.',
model: 'Model',
localModel: 'Model Tempatan',
create_helper: 'Tarik "{0}"',
@@ -1044,7 +1075,6 @@ const message = {
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 akaun model',
modelAccountAddressHelper:
'Alamat kontena sesuai untuk kontena agen; 127.0.0.1, IP sistem, atau alamat tersuai sesuai untuk AI Gateway atau perkhidmatan luaran.',
containerAddress: 'Alamat kontena',
@@ -1067,7 +1097,6 @@ const message = {
sortCreated: 'Terbaru dibuat',
downloadsCount: 'Muat turun',
likes: 'Suka',
taskQueue: 'Baris Gilir Muat Turun',
progress: 'Kemajuan',
localDownloaded: 'Dimuat turun',
modelInfo: 'Butiran Model',
+31 -2
View File
@@ -1023,6 +1023,37 @@ const message = {
validateImageAvailabilityHelper: 'Gera uma imagem mínima antes de salvar e pode gerar custos',
},
model: {
modelCategory: 'Categoria do modelo',
modelName: 'Nome do modelo',
modelArchitecture: 'Tipo de modelo',
backboneParams: 'Parâmetros base do MoE',
activeParams: 'Parâmetros ativos',
engram: 'Engram',
weightFootprint: 'Tamanho dos pesos',
undisclosed: 'Não divulgado',
featuredModels: 'Modelos em destaque',
deploy: 'Implantar',
approxWeightSize: 'Aprox. {0} GB',
services: 'Serviços de modelos',
addService: 'Adicionar serviço de modelo',
addEngine: 'Adicionar mecanismo de inferência',
onlineModel: 'Modelos online',
instanceDeployment: 'Implantação por instância',
nvidiaDeployment: 'Implantação otimizada para NVIDIA',
engines: 'Mecanismos de inferência',
modelManagement: 'Gerenciamento de modelos',
serviceDetails: 'Detalhes do serviço',
deployments: 'Implantações locais',
files: 'Arquivos de modelos',
connectExternal: 'Conectar serviço externo',
deploymentTemplates: 'Modelos de comandos de inicialização',
engineProvider: 'Mecanismo / Provedor',
endpoint: 'Endpoint',
cloudAccount: 'Conta na nuvem',
connectionStatus: 'Status da conexão',
unverified: 'Não verificado',
manage: 'Gerenciar',
loadFailed: 'Falha ao carregar. Atualize a página e tente novamente.',
model: 'Modelo',
localModel: 'Modelos locais',
create_helper: 'Puxar "{0}"',
@@ -1039,7 +1070,6 @@ const message = {
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 conta de modelo',
modelAccountAddressHelper:
'Use o endereço do contêiner para agentes; 127.0.0.1, IP do sistema ou endereço personalizado para AI Gateway ou serviços externos.',
containerAddress: 'Endereço do contêiner',
@@ -1062,7 +1092,6 @@ const message = {
sortCreated: 'Criados recentemente',
downloadsCount: 'Downloads',
likes: 'Curtidas',
taskQueue: 'Fila de downloads',
progress: 'Progresso',
localDownloaded: 'Baixados',
modelInfo: 'Detalhes do modelo',
+31 -2
View File
@@ -1017,6 +1017,37 @@ const message = {
'Перед сохранением создаётся одно минимальное изображение; возможна оплата',
},
model: {
modelCategory: 'Категория модели',
modelName: 'Название модели',
modelArchitecture: 'Тип модели',
backboneParams: 'Параметры основы MoE',
activeParams: 'Активные параметры',
engram: 'Engram',
weightFootprint: 'Размер весов',
undisclosed: 'Не раскрыто',
featuredModels: 'Рекомендуемые модели',
deploy: 'Развернуть',
approxWeightSize: 'Около {0} ГБ',
services: 'Сервисы моделей',
addService: 'Добавить сервис модели',
addEngine: 'Добавить движок инференса',
onlineModel: 'Онлайн-модели',
instanceDeployment: 'Развертывание по экземплярам',
nvidiaDeployment: 'Развертывание с оптимизацией для NVIDIA',
engines: 'Движки инференса',
modelManagement: 'Управление моделями',
serviceDetails: 'Сведения о сервисе',
deployments: 'Локальные развертывания',
files: 'Файлы моделей',
connectExternal: 'Подключить внешний сервис',
deploymentTemplates: 'Шаблоны команд запуска',
engineProvider: 'Движок / Поставщик',
endpoint: 'Конечная точка',
cloudAccount: 'Облачная учетная запись',
connectionStatus: 'Состояние подключения',
unverified: 'Не проверено',
manage: 'Управление',
loadFailed: 'Не удалось загрузить. Обновите страницу и повторите попытку.',
model: 'Модель',
localModel: 'Локальные модели',
create_helper: 'Загрузить "{0}"',
@@ -1032,7 +1063,6 @@ const message = {
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: 'Команда запуска должна использовать порт {0}, иначе сервис будет недоступен.',
ascendVisibleDevices: 'Видимые устройства Ascend (ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: 'Синхронизировать с аккаунтом модели',
modelAccountAddressHelper:
'Адрес контейнера подходит для контейнеров агентов; 127.0.0.1, системный IP или пользовательский адрес подходит для AI Gateway или внешних сервисов.',
containerAddress: 'Адрес контейнера',
@@ -1055,7 +1085,6 @@ const message = {
sortCreated: 'Недавно создано',
downloadsCount: 'Загрузки',
likes: 'Лайки',
taskQueue: 'Очередь загрузок',
progress: 'Прогресс',
localDownloaded: 'Загружено',
modelInfo: 'Сведения о модели',
+31 -2
View File
@@ -1027,6 +1027,37 @@ const message = {
validateImageAvailabilityHelper: 'Kaydetmeden önce bir küçük görsel üretir ve ücret doğurabilir',
},
model: {
modelCategory: 'Model kategorisi',
modelName: 'Model adı',
modelArchitecture: 'Model türü',
backboneParams: 'MoE omurga parametreleri',
activeParams: 'Etkin parametreler',
engram: 'Engram',
weightFootprint: 'Ağırlık boyutu',
undisclosed: 'Açıklanmadı',
featuredModels: 'Öne çıkan modeller',
deploy: 'Dağıt',
approxWeightSize: 'Yaklaşık {0} GB',
services: 'Model hizmetleri',
addService: 'Model hizmeti ekle',
addEngine: 'Çıkarım motoru ekle',
onlineModel: 'Çevrimiçi modeller',
instanceDeployment: 'Örnek bazında dağıtım',
nvidiaDeployment: 'NVIDIA için optimize edilmiş dağıtım',
engines: 'Çıkarım motorları',
modelManagement: 'Model yönetimi',
serviceDetails: 'Hizmet ayrıntıları',
deployments: 'Yerel dağıtımlar',
files: 'Model dosyaları',
connectExternal: 'Harici hizmete bağlan',
deploymentTemplates: 'Başlatma komutu şablonları',
engineProvider: 'Motor / Sağlayıcı',
endpoint: 'Uç nokta',
cloudAccount: 'Bulut hesabı',
connectionStatus: 'Bağlantı durumu',
unverified: 'Doğrulanmadı',
manage: 'Yönet',
loadFailed: 'Yüklenemedi. Yenileyip tekrar deneyin.',
model: 'Model',
localModel: 'Yerel Modeller',
create_helper: '"{0}" çek',
@@ -1043,7 +1074,6 @@ const message = {
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 hesabına senkronize et',
modelAccountAddressHelper:
'Konteyner adresi ajan konteynerleri içindir; 127.0.0.1, sistem IP veya özel adres AI Gateway ya da harici servisler içindir.',
containerAddress: 'Konteyner adresi',
@@ -1066,7 +1096,6 @@ const message = {
sortCreated: 'Yeni oluşturulanlar',
downloadsCount: 'İndirmeler',
likes: 'Beğeniler',
taskQueue: 'İndirme Kuyruğu',
progress: 'İlerleme',
localDownloaded: 'İndirilenler',
modelInfo: 'Model ayrıntıları',
+31 -2
View File
@@ -959,6 +959,37 @@ const message = {
validateImageAvailabilityHelper: '儲存前會實際產生一張最小圖片,可能產生費用',
},
model: {
modelCategory: '模型類別',
modelName: '模型名稱',
modelArchitecture: '模型類型',
backboneParams: 'MoE 骨幹參數',
activeParams: '啟用參數',
engram: 'Engram',
weightFootprint: '權重體積',
undisclosed: '未公開',
featuredModels: '精選模型',
deploy: '部署',
approxWeightSize: '約 {0} GB',
services: '模型服務',
addService: '新增模型服務',
addEngine: '新增推理引擎',
onlineModel: '線上模型',
instanceDeployment: '按執行個體部署',
nvidiaDeployment: 'NVIDIA 最佳化部署',
engines: '推理引擎',
modelManagement: '模型管理',
serviceDetails: '服務詳情',
deployments: '本機部署',
files: '模型檔案',
connectExternal: '接入外部服務',
deploymentTemplates: '啟動命令範本',
engineProvider: '引擎 / 供應商',
endpoint: '端點',
cloudAccount: '雲端帳號',
connectionStatus: '連線狀態',
unverified: '未驗證',
manage: '管理',
loadFailed: '載入失敗,請重新整理後重試',
model: '模型',
localModel: '本地模型',
create_helper: '拉取 "{0}"',
@@ -973,7 +1004,6 @@ const message = {
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '啟動命令必須使用 {0} 連接埠,否則服務將無法存取。',
ascendVisibleDevices: 'Ascend 可見裝置(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型帳號',
modelAccountAddressHelper:
'容器地址適合智能體容器呼叫;127.0.0.1、本機 IP 或自訂地址適合 AI 閘道或外部服務呼叫。',
containerAddress: '容器地址',
@@ -995,7 +1025,6 @@ const message = {
sortCreated: '最新建立',
downloadsCount: '下載量',
likes: '點讚',
taskQueue: '下載佇列',
progress: '進度',
localDownloaded: '已下載',
modelInfo: '模型詳情',
+31 -2
View File
@@ -972,6 +972,37 @@ const message = {
validateImageAvailabilityHelper: '保存前会真实生成一张最小图片,可能产生费用',
},
model: {
modelCategory: '模型类别',
modelName: '模型名称',
modelArchitecture: '模型类型',
backboneParams: 'MoE 骨干参数',
activeParams: '激活参数',
engram: 'Engram',
weightFootprint: '权重体积',
undisclosed: '未公开',
featuredModels: '精选模型',
deploy: '部署',
approxWeightSize: '约 {0} GB',
services: '模型服务',
addService: '添加模型服务',
addEngine: '添加推理引擎',
onlineModel: '在线模型',
instanceDeployment: '按实例部署',
nvidiaDeployment: 'NVIDIA 优化部署',
engines: '推理引擎',
modelManagement: '模型管理',
serviceDetails: '服务详情',
deployments: '本地部署',
files: '模型文件',
connectExternal: '接入外部服务',
deploymentTemplates: '启动命令模板',
engineProvider: '引擎 / 供应商',
endpoint: '端点',
cloudAccount: '云端账号',
connectionStatus: '连接状态',
unverified: '未验证',
manage: '管理',
loadFailed: '加载失败,请刷新重试',
model: '模型',
localModel: '本地模型',
create_helper: '拉取 "{0}"',
@@ -986,7 +1017,6 @@ const message = {
vllmProfileVisionExp: 'DeepSeek V4 Flash Vision Exp',
vllmCommandPortHelper: '启动命令必须使用 {0} 端口,否则服务将无法访问。',
ascendVisibleDevices: 'Ascend 可见设备(ASCEND_RT_VISIBLE_DEVICES)',
syncModelAccount: '同步到模型账号',
modelAccountAddressHelper:
'容器地址适合智能体容器调用;127.0.0.1、本机 IP 或自定义地址适合 AI 网关或外部服务调用。',
containerAddress: '容器地址',
@@ -1008,7 +1038,6 @@ const message = {
sortCreated: '最新创建',
downloadsCount: '下载量',
likes: '点赞',
taskQueue: '下载队列',
progress: '进度',
localDownloaded: '已下载',
modelInfo: '模型详情',
+22 -11
View File
@@ -11,16 +11,6 @@ const aiRouter = {
title: 'menu.aiTools',
},
children: [
{
path: '/ai/agents/agent',
name: 'Agents',
component: () => import('@/views/ai/agents/agent/index.vue'),
meta: {
icon: 'p-jiqiren2',
title: 'aiTools.agents.agent',
permission: 'ai_agent_view',
},
},
{
path: '/ai/model/account',
name: 'AIModel',
@@ -37,11 +27,32 @@ const aiRouter = {
name: 'LocalModel',
component: () => import('@/views/ai/model/index.vue'),
meta: {
title: 'aiTools.model.localModel',
title: 'aiTools.model.deployments',
activeMenu: '/ai/model/account',
permission: 'ai_model_view',
},
},
{
path: '/ai/model/files',
hidden: true,
name: 'ModelFiles',
component: () => import('@/views/ai/model/index.vue'),
meta: {
title: 'aiTools.model.files',
activeMenu: '/ai/model/account',
permission: 'ai_model_view',
},
},
{
path: '/ai/agents/agent',
name: 'Agents',
component: () => import('@/views/ai/agents/agent/index.vue'),
meta: {
icon: 'p-jiqiren2',
title: 'aiTools.agents.agent',
permission: 'ai_agent_view',
},
},
{
path: '/ai/mcp',
name: 'MCPServer',
@@ -28,6 +28,14 @@ const volcengineLogo: AgentProviderLogo = {
};
const providerLogos: Record<string, AgentProviderLogo> = {
nvidia: {
src: asset('nvidia.ico'),
mark: 'NV',
background: '#ffffff',
color: '#76b900',
borderColor: '#dcdfe6',
source: 'https://www.nvidia.com/favicon.ico',
},
ollama: {
src: asset('ollama.webp'),
mark: 'OL',
+3
View File
@@ -38,6 +38,9 @@ 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({
@@ -156,7 +156,7 @@ import {
} from '@/utils/agent';
import { App } from '@/api/interface/app';
import AdvancedSetting from '@/components/advanced-setting/index.vue';
import AccountAddDialog from '@/views/ai/agents/model/add/index.vue';
import AccountAddDialog from '@/views/ai/model/components/account-form/index.vue';
import { useGlobalStore } from '@/composables/useGlobalStore';
import i18n from '@/lang';
@@ -1,221 +0,0 @@
<template>
<div>
<LayoutContent>
<template #leftToolBar>
<el-button v-permission type="primary" @click="openCreate">
{{ $t('commons.button.create') }}
</el-button>
</template>
<template #rightToolBar>
<el-select
v-model="apiType"
clearable
filterable
class="api-type-filter"
:placeholder="'API ' + $t('commons.table.type')"
@change="handleAPITypeChange"
>
<el-option v-for="item in apiTypeOptions" :key="item" :label="item" :value="item">
<ApiTypeTag :api-type="item" />
</el-option>
</el-select>
<TableSearch v-model:searchName="searchName" @search="search" />
<TableRefresh @search="search" />
</template>
<template #main>
<ComplexTable :data="items" :pagination-config="paginationConfig" @search="search">
<el-table-column :label="$t('commons.table.name')" prop="name" min-width="200" />
<el-table-column :label="$t('aiTools.agents.provider')" prop="provider" width="200">
<template #default="{ row }">
<ProviderLabel :provider="row.provider" :display-name="row.providerName" />
</template>
</el-table-column>
<el-table-column :label="'API ' + $t('commons.table.type')" prop="apiType" min-width="170">
<template #default="{ row }">
<ApiTypeTag :api-type="row.apiType" />
</template>
</el-table-column>
<el-table-column label="Base URL" prop="baseUrl" min-width="200" />
<el-table-column label="API Key" prop="apiKey" min-width="160">
<template #default="{ row }">
{{ maskKey(row.apiKey) }}
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.verified')" prop="verified" width="120">
<template #default="{ row }">
<el-tag :type="verificationTagType(row)">
{{ verificationLabel(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.date')"
prop="createdAt"
width="180"
:formatter="dateFormat"
/>
<fu-table-operations
:buttons="buttons"
:label="$t('commons.table.operate')"
fixed="right"
width="180"
/>
</ComplexTable>
</template>
</LayoutContent>
<AddDialog ref="addRef" @search="search" />
<ModelPoolDialog ref="modelPoolRef" @updated="search" />
</div>
</template>
<script setup lang="ts">
import { useSearchState } from '@/utils/search-reset';
import { toRef, onMounted, reactive, ref } from 'vue';
import { deleteAgentAccount, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import AddDialog from '@/views/ai/agents/model/add/index.vue';
import ModelPoolDialog from '@/views/ai/agents/model/pool/index.vue';
import ProviderLabel from '@/components/agent-provider-label/index.vue';
import ApiTypeTag from '@/components/api-type-tag/index.vue';
import { ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import { dateFormat } from '@/utils/date';
import { isAgentAccountVerificationSkipped } from '@/utils/agent';
const items = ref<AI.AgentAccountItem[]>([]);
const addRef = ref();
const modelPoolRef = ref();
const searchName = ref('');
const apiType = ref('');
const apiTypeOptions = ref<string[]>([]);
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
permission: true,
click: (row: AI.AgentAccountItem) => onEdit(row),
},
{
label: i18n.global.t('aiTools.agents.modelPool'),
click: (row: AI.AgentAccountItem) => onManageModelPool(row),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
click: (row: AI.AgentAccountItem) => onDelete(row),
},
];
const paginationConfig = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
useSearchState(
'ai/agents/model/index',
{ search: searchName, page: toRef(paginationConfig, 'currentPage'), pageSize: toRef(paginationConfig, 'pageSize') },
{ onRevisit: () => search() },
);
const search = async () => {
const req: AI.AgentAccountSearch = {
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
provider: '',
apiType: apiType.value,
name: searchName.value || '',
};
const res = await pageAgentAccounts(req);
items.value = res.data.items || [];
paginationConfig.total = res.data.total || 0;
};
const handleAPITypeChange = () => {
paginationConfig.currentPage = 1;
search();
};
const loadAPITypes = async () => {
const res = await getAgentProviders();
apiTypeOptions.value = Array.from(
new Set((res.data || []).flatMap((provider) => provider.apiTypes.map((item) => item.apiType))),
).sort();
};
const openCreate = () => {
if (addRef.value?.open) {
addRef.value.open();
}
};
const onEdit = (row: AI.AgentAccountItem) => {
if (addRef.value?.open) {
addRef.value.open({
id: row.id,
provider: row.provider,
name: row.name,
baseURL: row.baseUrl,
apiKey: row.apiKey,
rememberApiKey: row.rememberApiKey,
apiType: row.apiType,
authMode: row.authMode,
verifyModel: row.verifyModel,
models: row.models,
remark: row.remark,
});
}
};
const onManageModelPool = (row: AI.AgentAccountItem) => {
if (modelPoolRef.value?.open) {
modelPoolRef.value.open(row);
}
};
const verificationLabel = (row: AI.AgentAccountItem) => {
if (isAgentAccountVerificationSkipped(row.provider)) {
return i18n.global.t('aiTools.agents.verifySkipped');
}
return row.verified ? 'OK' : 'N/A';
};
const verificationTagType = (row: AI.AgentAccountItem) => {
if (isAgentAccountVerificationSkipped(row.provider)) {
return 'info';
}
return row.verified ? 'success' : 'info';
};
const maskKey = (value: string) => {
if (!value) {
return '';
}
if (value.length <= 6) {
return value;
}
return `${value.slice(0, 3)}****${value.slice(-3)}`;
};
const onDelete = async (row: AI.AgentAccountItem) => {
await ElMessageBox.confirm(
i18n.global.t('commons.msg.delete', [row.name]),
i18n.global.t('commons.button.delete'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
);
await deleteAgentAccount({ id: row.id });
await search();
};
onMounted(async () => {
await Promise.all([search(), loadAPITypes()]);
});
</script>
<style scoped>
.api-type-filter {
width: 220px;
}
</style>
@@ -0,0 +1,88 @@
<template>
<div class="form-section-title">{{ $t('aiTools.agents.account') }}</div>
<span class="input-help model-account-helper">{{ $t('aiTools.model.modelAccountAddressHelper') }}</span>
<el-form-item
:label="$t('aiTools.agents.allowedOrigins')"
prop="modelAccountBaseURLType"
:rules="Rules.requiredSelect"
>
<el-radio-group v-model="form.modelAccountBaseURLType">
<el-radio-button
v-for="item in addressTypes"
:key="item.value"
:value="item.value"
:disabled="hostMode && item.value === 'container'"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="form.modelAccountBaseURLType === 'custom'"
label="Base URL"
prop="modelAccountBaseURL"
:rules="customURLRules"
>
<el-input v-model.trim="form.modelAccountBaseURL" placeholder="http://192.168.1.100:8000/v1" />
</el-form-item>
<el-form-item v-else label="Base URL">
<el-input :model-value="baseURL" readonly />
</el-form-item>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { getAgentSettingInfo } from '@/api/modules/setting';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
const props = defineProps<{
form: {
name: string;
containerName: string;
modelAccountBaseURLType: string;
modelAccountBaseURL: string;
};
hostPort: number;
containerPort: string;
hostMode?: boolean;
}>();
const systemIP = ref('');
const addressTypes = computed(() => [
{ label: i18n.global.t('aiTools.model.containerAddress'), value: 'container' },
{ label: '127.0.0.1', value: 'localhost' },
{ label: i18n.global.t('setting.systemIP'), value: 'systemIP' },
{ label: i18n.global.t('aiTools.model.customAddress'), value: 'custom' },
]);
const customURLRules = [
Rules.requiredInput,
{ pattern: /^https?:\/\/.+/i, message: i18n.global.t('aiTools.agents.allowedOriginsInvalid'), trigger: 'blur' },
];
const baseURL = computed(() => {
if (props.form.modelAccountBaseURLType === 'custom') return props.form.modelAccountBaseURL;
if (props.form.modelAccountBaseURLType === 'localhost') return `http://127.0.0.1:${props.hostPort}/v1`;
if (props.form.modelAccountBaseURLType === 'systemIP')
return `http://${systemIP.value || '127.0.0.1'}:${props.hostPort}/v1`;
return `http://${props.form.containerName || props.form.name}:${props.containerPort}/v1`;
});
onMounted(async () => {
try {
const { data } = await getAgentSettingInfo();
systemIP.value = data.systemIP || '';
} catch {
systemIP.value = '';
}
});
defineExpose({ baseURL });
</script>
<style scoped>
.form-section-title {
font-weight: 600;
margin-bottom: 12px;
}
.model-account-helper {
display: block;
margin-bottom: 12px;
}
</style>
@@ -1,5 +1,10 @@
<template>
<DrawerPro v-model="open" :header="headerTitle" size="large" @close="handleClose">
<component
:is="inline ? 'div' : DrawerPro"
v-bind="inline ? {} : { modelValue: open, header: headerTitle, size: 'large' }"
@update:model-value="open = $event"
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" autocomplete="off" v-loading="loading">
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input v-model="form.name" />
@@ -229,7 +234,7 @@
<span class="input-help">{{ $t('aiTools.agents.syncAgentsHelper') }}</span>
</el-form-item>
</el-form>
<template #footer>
<template v-if="!inline" #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="open = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button v-permission :disabled="loading" type="primary" @click="submit">
@@ -237,7 +242,7 @@
</el-button>
</span>
</template>
</DrawerPro>
</component>
</template>
<script setup lang="ts">
@@ -257,7 +262,10 @@ import { MsgError } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore';
import ProviderLogo from '@/components/agent-provider-logo/index.vue';
const emit = defineEmits(['search']);
import DrawerPro from '@/components/drawer-pro/index.vue';
const props = defineProps({ inline: Boolean });
const emit = defineEmits(['search', 'close']);
const open = ref(false);
const formRef = ref<FormInstance>();
@@ -291,7 +299,7 @@ const form = reactive({
});
const headerTitle = computed(() =>
form.id ? i18n.global.t('commons.button.edit') : i18n.global.t('commons.button.create'),
form.id ? i18n.global.t('commons.button.edit') : i18n.global.t('aiTools.model.connectExternal'),
);
const selectedProvider = computed(() => providers.value[form.provider]);
@@ -641,6 +649,7 @@ const submit = async () => {
};
const handleClose = () => {
emit('close');
formRef.value?.resetFields();
loading.value = false;
form.id = 0;
@@ -769,12 +778,18 @@ watch([() => form.apiKey, () => form.baseURL], () => {
}
});
watch(open, (value) => {
if (props.inline && !value) handleClose();
});
onMounted(async () => {
await loadProviders();
if (!props.inline) await loadProviders();
});
defineExpose({
open: openDrawer,
submit,
loading,
});
</script>
@@ -0,0 +1,273 @@
<template>
<DialogPro
v-model="drawerVisible"
:title="$t(engine === 'vllm' ? 'aiTools.model.serviceDetails' : 'database.databaseConnInfo')"
:size="isMobile ? 'full' : 'normal'"
@close="handleClose"
>
<div class="connection-details">
<div v-if="form.name" class="service-heading">
<el-tag effect="plain">
{{ engine === 'ollama' ? 'Ollama' : engine === 'vllm' ? 'vLLM' : 'TensorRT LLM' }}
</el-tag>
<strong>{{ form.name }}</strong>
</div>
<section v-if="engine === 'vllm'" class="detail-card">
<h3 class="detail-title">
<el-icon><FolderOpened /></el-icon>
{{ $t('aiTools.model.modelDir') }}
</h3>
<div class="detail-value model-directory">
<code>{{ form.modelDir || '—' }}</code>
<CopyButton v-if="form.modelDir" :content="form.modelDir" />
</div>
</section>
<section class="detail-card">
<h3 class="detail-title">
<el-icon><Connection /></el-icon>
{{ $t('database.containerConn') }}
</h3>
<div class="detail-row">
<span class="detail-label">{{ $t('app.containerName') }}</span>
<div class="detail-value">
<code>{{ form.containerName }}</code>
<CopyButton :content="form.containerName" />
</div>
</div>
<div v-if="form.containerPort" class="detail-row">
<span class="detail-label">{{ $t('commons.table.port') }}</span>
<div class="detail-value">
<code>{{ form.containerPort }}</code>
<CopyButton :content="form.containerPort" />
</div>
</div>
<p class="detail-help">{{ $t('aiTools.model.container_conn_helper') }}</p>
</section>
<section class="detail-card">
<h3 class="detail-title">
<el-icon><Link /></el-icon>
{{ $t(bindDomain.connUrl ? 'setting.proxyUrl' : 'database.remoteConn') }}
</h3>
<div class="detail-row">
<span class="detail-label">{{ $t('database.connAddress') }}</span>
<div class="detail-value">
<code>{{ externalAddress }}</code>
<CopyButton :content="externalAddress" />
</div>
</div>
<div
v-if="engine !== 'ollama' && !bindDomain.connUrl && form.port && !form.endpoint"
class="detail-row"
>
<span class="detail-label">{{ $t('commons.table.port') }}</span>
<div class="detail-value">
<code>{{ form.port }}</code>
<CopyButton :content="form.port + ''" />
</div>
</div>
<p class="detail-help">{{ $t('database.remoteConnHelper2') }}</p>
</section>
<el-button v-if="engine === 'ollama'" @click="openWebUI">OpenWebUI</el-button>
</div>
<template #footer>
<el-button @click="handleClose">
{{ $t('commons.button.close') }}
</el-button>
</template>
</DialogPro>
<PortJumpDialog ref="portRef" />
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { Connection, FolderOpened, Link } from '@element-plus/icons-vue';
import i18n from '@/lang';
import { ElMessageBox } from 'element-plus';
import { getAppPort } from '@/api/modules/app';
import { useRouter } from 'vue-router';
import PortJumpDialog from '@/components/port-jump/index.vue';
import { getAgentSettingInfo } from '@/api/modules/setting';
import { getBindDomain } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { currentNode, isMobile } = useGlobalStore();
const emit = defineEmits(['close']);
const router = useRouter();
const portRef = ref();
const engine = ref('ollama');
const drawerVisible = ref(false);
const form = reactive({
name: '',
modelDir: '',
systemIP: '',
containerName: '',
port: 0 as number | string,
containerPort: '11434',
endpoint: '',
});
const bindDomain = ref({
connUrl: '',
});
const externalAddress = computed(() => {
if (bindDomain.value.connUrl) return bindDomain.value.connUrl;
if (form.endpoint) return form.endpoint;
if (engine.value !== 'ollama' || !form.port || !form.systemIP) return form.systemIP;
const host = form.systemIP.includes(':') && !form.systemIP.startsWith('[') ? `[${form.systemIP}]` : form.systemIP;
return `http://${host}:${form.port}`;
});
interface DialogProps {
name?: string;
modelDir?: string;
port: number | string;
containerPort?: string;
endpoint?: string;
engine?: string;
containerName: string;
appinstallID?: number;
}
const acceptParams = (param: DialogProps): void => {
engine.value = param.engine || 'ollama';
form.name = param.name || '';
form.modelDir = param.modelDir || '';
form.containerPort = param.containerPort ?? '11434';
form.endpoint = param.endpoint || '';
bindDomain.value.connUrl = '';
form.containerName = param.containerName;
form.port = param.port;
loadSystemIP();
loadBindDomain(param.appinstallID);
drawerVisible.value = true;
};
const openWebUI = async () => {
const node = currentNode.value;
const { data } = await getAppPort('ollama-webui', '');
if (node !== currentNode.value || !drawerVisible.value) return;
if (data) {
portRef.value.acceptParams({ port: data });
return;
}
await ElMessageBox.confirm(i18n.global.t('app.checkInstalledWarn', ['OpenWebUI']), 'OpenWebUI', {
confirmButtonText: i18n.global.t('database.goInstall'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
});
if (node !== currentNode.value || !drawerVisible.value) return;
await router.push({ name: 'AppAll', query: { install: 'ollama-webui' } });
};
const handleClose = () => {
drawerVisible.value = false;
emit('close');
};
const loadSystemIP = async () => {
if (currentNode.value !== 'local') {
form.systemIP = currentNode.value || i18n.global.t('database.localIP');
return;
}
const res = await getAgentSettingInfo();
form.systemIP = res.data.systemIP || i18n.global.t('database.localIP');
};
const loadBindDomain = async (appInstallID: number) => {
if (appInstallID == undefined || appInstallID <= 0) {
return;
}
try {
const res = await getBindDomain({
appInstallID: appInstallID,
});
if (res.data.websiteID > 0) {
bindDomain.value.connUrl = res.data.connUrl;
}
} catch (e) {}
};
defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.connection-details {
display: grid;
gap: 16px;
}
.service-heading {
display: flex;
align-items: center;
gap: 12px;
color: var(--el-text-color-primary);
strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 16px;
}
}
.detail-card {
padding: 18px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
}
.detail-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
.el-icon {
color: var(--el-color-primary);
font-size: 18px;
}
}
.detail-row {
display: grid;
grid-template-columns: 80px minmax(0, 1fr);
gap: 12px;
align-items: baseline;
& + & {
margin-top: 12px;
}
}
.detail-label,
.detail-help {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.detail-value {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
code {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.6;
font-size: 13px;
color: var(--el-text-color-primary);
}
:deep(.el-button) {
flex-shrink: 0;
}
}
.model-directory {
padding: 12px;
border-radius: 6px;
background: var(--el-bg-color);
}
.detail-help {
margin: 14px 0 0;
line-height: 1.6;
}
@media (max-width: 768px) {
.detail-row {
grid-template-columns: minmax(0, 1fr);
gap: 4px;
}
}
</style>
@@ -0,0 +1,315 @@
<template>
<DrawerPro
v-model="visible"
:header="$t(enginesOnly ? 'aiTools.model.addEngine' : 'aiTools.model.addService')"
:size="isMobile ? 'full' : '50%'"
>
<div class="engine-options">
<el-button
v-if="!enginesOnly && !vllmOnly"
class="engine-option"
:class="{ 'is-selected': selected === 'account' }"
:aria-pressed="selected === 'account'"
@click="selected = 'account'"
>
<el-icon><Cloudy /></el-icon>
<span class="engine-content">
<span class="engine-name">{{ $t('aiTools.model.onlineModel') }}</span>
<span class="engine-description">{{ $t('aiTools.model.cloudAccount') }}</span>
</span>
</el-button>
<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>
<el-button
v-if="isFxplay && !vllmOnly"
class="engine-option"
:class="{ 'is-selected': selected === 'tensorrt' }"
:aria-pressed="selected === 'tensorrt'"
@click="selected = 'tensorrt'"
>
<ProviderLogo provider="nvidia" :size="18" />
<span class="engine-content">
<span class="engine-name">TensorRT LLM</span>
<span class="engine-description">{{ $t('aiTools.model.nvidiaDeployment') }}</span>
</span>
</el-button>
</div>
<el-divider />
<DockerStatus
v-if="visible"
v-show="selected !== 'account'"
v-model:is-active="dockerActive"
v-model:is-exist="dockerExists"
/>
<div v-loading="initializing" class="service-form">
<el-alert v-if="loadFailed" :title="$t('aiTools.model.loadFailed')" type="error" :closable="false" />
<AccountForm
v-if="activeForm === 'account'"
ref="accountRef"
inline
@search="emit('search')"
@close="visible = false"
/>
<component
:is="vllmForm"
v-if="activeForm === 'vllm' && vllmForm"
ref="vllmRef"
inline
@search="emit('search')"
@close="visible = false"
@task="openTask"
/>
<OllamaForm
v-if="activeForm === 'ollama'"
ref="ollamaRef"
inline
@search="emit('search')"
@close="visible = false"
@task="openTask"
/>
<TensorForm
v-if="activeForm === 'tensorrt'"
ref="tensorRef"
inline
@search="emit('search')"
@close="visible = false"
/>
<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>
<template v-if="selected === 'ollama' && ollama?.isExist">
<el-button type="primary" :disabled="!ollama" @click="openOllama">
{{ $t(ollama?.isExist ? 'aiTools.model.manage' : 'commons.button.install') }}
</el-button>
</template>
</div>
<template v-if="activeForm" #footer>
<el-button :disabled="currentForm?.loading" @click="visible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button
v-permission
type="primary"
:loading="initializing || currentForm?.loading"
:disabled="initializing || loadFailed || !currentForm || currentForm.disabled"
@click="currentForm.submit()"
>
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</DrawerPro>
<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 { Cloudy } from '@element-plus/icons-vue';
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 TensorForm from '@/views/ai/model/tensorrt/operate/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 } from '@/api/modules/app';
import { vllmAvailable } from '../../deployment';
const props = defineProps({ enginesOnly: Boolean, vllmOnly: Boolean });
const emit = defineEmits(['search', 'manage']);
const { isProductPro, isFxplay, isMobile } = useGlobalStore();
const visible = ref(false);
const selected = ref<'account' | 'vllm' | 'ollama' | 'tensorrt'>('account');
const modelDir = ref('');
const dockerActive = ref(false);
const dockerExists = ref(true);
const ollama = ref<App.CheckInstalled>();
const accountRef = ref();
const editAccountRef = ref();
const tensorRef = ref();
const ollamaRef = ref();
const vllmRef = ref();
const taskRef = ref();
const vllmForm = shallowRef();
const initializing = ref(false);
const loadFailed = ref(false);
const activeForm = computed(() => {
if (!visible.value) return '';
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';
if (selected.value === 'tensorrt' && isFxplay.value) return 'tensorrt';
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;
if (activeForm.value === 'tensorrt') return tensorRef.value;
return undefined;
});
watch(
activeForm,
async (form, _, onCleanup) => {
let cancelled = false;
onCleanup(() => (cancelled = true));
loadFailed.value = false;
initializing.value = Boolean(form);
if (!form) return;
try {
if (form === 'vllm' && !vllmForm.value) {
const component = await loadOptionalComponent('/src/xpack/views/vllm/operate/index.vue');
if (cancelled) return;
vllmForm.value = component;
}
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 === 'tensorrt') tensorRef.value.openCreate();
} catch {
if (!cancelled) loadFailed.value = true;
} finally {
if (!cancelled) initializing.value = false;
}
},
{ flush: 'post' },
);
const open = async (engine?: 'ollama' | 'vllm' | 'tensorrt', defaultModelDir = '') => {
loadFailed.value = false;
modelDir.value = defaultModelDir;
selected.value = props.vllmOnly ? 'vllm' : engine || (props.enginesOnly ? 'vllm' : 'account');
dockerActive.value = false;
dockerExists.value = true;
ollama.value = undefined;
visible.value = true;
if (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 openVllmLicense = () => {
visible.value = false;
emit('manage', { engine: 'vllm', 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);
}
.service-form {
min-height: 100px;
}
@media (max-width: 600px) {
.engine-options {
grid-auto-flow: row;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,402 @@
<template>
<DrawerPro
v-model="drawerVisible"
:header="header"
:resource="instance?.name"
:size="isMobile ? 'full' : 'large'"
@close="emit('search')"
>
<template #content>
<div class="deployment-action" v-loading="loading">
<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="flex flex-wrap gap-3 items-center mb-4">
<strong>{{ engineLabels[instance.engine] }} {{ instance.version }}</strong>
<Status :status="instance.status" />
</div>
<div class="service-actions">
<el-button
v-for="button in visibleButtons"
:key="button.label"
:disabled="
deleteLoading ||
(button.disabled?.(instance) ?? false) ||
(button.permission && !hasManagePermissionAccess())
"
@click="button.click(instance)"
>
{{ button.label }}
</el-button>
</div>
</div>
<el-descriptions v-if="instance.engine !== 'ollama'" :column="1" border>
<el-descriptions-item :label="$t('commons.table.name')">
{{ instance.name }}
</el-descriptions-item>
<el-descriptions-item :label="$t('menu.container')">
{{ instance.containerName }}
</el-descriptions-item>
<el-descriptions-item :label="$t('aiTools.model.modelDir')">
{{ instance.modelDir || '—' }}
</el-descriptions-item>
</el-descriptions>
</template>
<OllamaModels
v-if="visible && !standalone && instance?.engine === 'ollama' && !failed && !missing"
:key="instance.id"
:instance-name="instance.name"
/>
<VllmPlaceholder v-if="visible && view === VllmPlaceholder" />
</div>
</template>
</DrawerPro>
<component
:is="view"
v-if="visible && view && view !== VllmPlaceholder && !failed && !missing"
ref="contentRef"
v-bind="
view === EngineConnection
? {}
: view === ContainerLogs
? { highlightDiff: 210 }
: view === TaskLog
? { width: '70%' }
: { dialog: true }
"
@close="returnToManagement"
@search="emit('search')"
@task="openTask"
/>
<DialogPro
v-model="deleteVisible"
:title="$t('commons.button.delete') + ' - ' + (deleting?.name || '')"
size="small"
>
<p>{{ $t('commons.msg.delete', [deleting?.name || '']) }}</p>
<el-checkbox v-if="deleting?.engine === 'vllm'" v-model="forceDelete" :label="$t('website.forceDelete')" />
<template #footer>
<el-button :disabled="deleteLoading" @click="deleteVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button v-permission type="primary" :loading="deleteLoading" @click="deleteDeployment">
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</DialogPro>
</template>
<script setup lang="ts">
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 { pageTensorRTLLM, deleteTensorRTLLM } from '@/api/modules/ai';
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 OllamaModels from '../../ollama/models/index.vue';
import EngineConnection from '../connection/index.vue';
import TensorForm from '../../tensorrt/operate/index.vue';
import VllmPlaceholder from '../../vllm/index.vue';
import ContainerLogs from '@/components/log/container-drawer/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import { readPages, loadVllmAPI, vllmAvailable, type Deployment } from '../../deployment';
const emit = defineEmits(['search', 'upgrade']);
const { isMobile, currentNode, isProductPro, isFxplay } = useGlobalStore();
const route = useRoute();
const visible = ref(false);
const standalone = ref(false);
const drawerVisible = computed({
get: () => visible.value && !standalone.value,
set: (value) => {
visible.value = value;
},
});
const loading = ref(false);
const failed = ref(false);
const missing = ref(false);
const header = ref('');
const instance = ref<Deployment>();
const actionName = ref('manage');
const deleteVisible = ref(false);
const deleting = ref<Deployment>();
const forceDelete = ref(false);
const deleteLoading = ref(false);
const deleted = ref(false);
const engineLabels = { vllm: 'vLLM', ollama: 'Ollama', tensorrt: 'TensorRT LLM' };
const view = shallowRef();
const contentRef = ref();
let request = 0;
const openTask = async (taskID: string) => {
emit('search');
visible.value = true;
actionName.value = 'task';
view.value = markRaw(TaskLog);
await nextTick();
contentRef.value?.openWithTaskID(taskID);
};
const openUpgrade = async (installId: number, version: number) => {
const installed = await readPages((page) =>
searchAppInstalled({ ...page, name: instance.value.name, update: true }),
);
if (version !== request || !visible.value) return;
const app = installed.find((item) => item.id === installId);
if (!app) {
missing.value = true;
if (standalone.value) MsgError(i18n.global.t('commons.res.notFound'));
return;
}
if (standalone.value) visible.value = false;
emit('upgrade', app);
};
const open = async (
row: Pick<Deployment, 'engine' | 'id' | 'name'> & Partial<Deployment>,
action = 'manage',
outside = false,
) => {
const version = ++request;
standalone.value = outside;
if (row.engine === 'tensorrt' && !isFxplay.value) return;
instance.value = { version: '', containerName: '', status: '', modelDir: '', models: [], ...row };
actionName.value = action;
deleted.value = false;
header.value = `${engineLabels[row.engine]} ${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)) {
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,
containerName: data.containerName,
};
if (action === 'upgrade') {
await openUpgrade(data.appInstallId, version);
return;
}
const component = action === 'connection' ? EngineConnection : action === 'log' ? ContainerLogs : undefined;
view.value = component ? markRaw(component) : undefined;
await nextTick();
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 =
row.engine === 'vllm'
? await readPages((page) => loadVllmAPI().then((api) => api.pageVllm({ ...page, info: row.name })))
: await readPages((page) => pageTensorRTLLM({ ...page, name: row.name }));
const detail = items.find((item) => item.id === row.id);
if (version !== request || !visible.value) return;
if (!detail) {
missing.value = true;
if (standalone.value) MsgError(i18n.global.t('commons.res.notFound'));
return;
}
instance.value = {
...instance.value,
status: detail.status,
containerName: detail.containerName,
modelDir: detail.modelDir,
version: 'appVersion' in detail ? detail.appVersion : detail.version,
upgradable: 'upgradable' in detail && detail.upgradable,
};
if (action === 'connection') {
view.value = markRaw(EngineConnection);
await nextTick();
contentRef.value?.acceptParams({
containerName: detail.containerName,
port:
'port' in detail
? detail.port
: (detail.exposedPorts || []).map((item) => item.hostPort).join(', '),
containerPort:
'port' in detail ? '' : (detail.exposedPorts || []).map((item) => item.containerPort).join(', '),
endpoint: 'modelAccountBaseURL' in detail ? detail.modelAccountBaseURL : '',
engine: row.engine,
name: row.name,
modelDir: detail.modelDir,
});
} else if (action === 'delete') {
deleting.value = instance.value;
forceDelete.value = false;
deleteVisible.value = true;
} else if (action === 'config') {
const component =
row.engine === 'vllm'
? await loadOptionalComponent('/src/xpack/views/vllm/operate/index.vue')
: TensorForm;
if (version !== request || !visible.value) return;
view.value = markRaw(component);
await nextTick();
await contentRef.value?.openEdit(detail);
} else if (action === 'monitor') {
const component = await loadOptionalComponent('/src/xpack/views/vllm/monitor/index.vue');
if (version !== request || !visible.value) return;
view.value = markRaw(component);
await nextTick();
contentRef.value?.acceptParams(detail);
} else if (action === 'upgrade' && 'appInstallId' in detail) {
await openUpgrade(detail.appInstallId, version);
} else if (action === 'log') {
const task = 'appInstallId' in detail && ['Installing', 'Uninstalling'].includes(detail.status);
view.value = markRaw(task ? TaskLog : ContainerLogs);
await nextTick();
if (task) {
contentRef.value?.openWithResourceID(
'App',
detail.status === 'Installing' ? 'TaskInstall' : 'TaskUninstall',
detail.appInstallId,
);
} else {
contentRef.value?.acceptParams({
containerID: detail.containerName,
container: detail.containerName,
});
}
}
} catch {
if (version === request) {
failed.value = true;
if (standalone.value) MsgError(i18n.global.t('aiTools.model.loadFailed'));
}
} finally {
if (version === request) loading.value = false;
}
};
watch(visible, (value) => {
if (!value) request++;
});
watch([currentNode, () => route.fullPath, isProductPro, isFxplay], () => {
request++;
visible.value = false;
deleteVisible.value = false;
deleting.value = undefined;
});
const operationDisabled = (row: Deployment) =>
deleteLoading.value ||
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Waiting', 'Uninstalling'].includes(row.status);
const deleteDeployment = async () => {
const version = request;
const row = deleting.value;
if (!row) return;
deleteLoading.value = true;
try {
if (row.engine === 'tensorrt') {
await deleteTensorRTLLM({ id: row.id });
} else {
const taskID = newUUID();
await (
await loadVllmAPI()
).operateVllm({ id: row.id, operate: 'delete', taskID, forceDelete: forceDelete.value });
if (version !== request || !visible.value) return;
await openTask(taskID);
}
if (version !== request || !visible.value) return;
deleted.value = true;
deleteVisible.value = false;
emit('search');
if (row.engine === 'tensorrt') visible.value = false;
} finally {
deleteLoading.value = false;
}
};
const deploymentButtons = [
{
label: i18n.global.t('commons.button.edit'),
permission: true,
disabled: operationDisabled,
click: (row: Deployment) => open(row, 'config'),
},
{
label: i18n.global.t('menu.monitor'),
show: (row: Deployment) => row.engine === 'vllm',
click: (row: Deployment) => open(row, 'monitor'),
},
{
label: i18n.global.t('commons.button.upgrade'),
permission: true,
show: (row: Deployment) => row.upgradable && hasPermission('app_manage'),
disabled: operationDisabled,
click: (row: Deployment) => open(row, 'upgrade'),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
disabled: (row: Deployment) => operationDisabled(row) && row.status !== 'Uninstalling',
click: (row: Deployment) => {
deleting.value = row;
forceDelete.value = false;
deleteVisible.value = true;
},
},
];
const visibleButtons = computed(() =>
instance.value ? deploymentButtons.filter((button) => !button.show || button.show(instance.value)) : [],
);
const returnToManagement = async () => {
const previousView = view.value;
await nextTick();
if (view.value !== previousView) return;
if (actionName.value === 'task' || actionName.value === 'upgrade') emit('search');
if (deleted.value || standalone.value) visible.value = false;
else if (instance.value) open(instance.value);
};
defineExpose({ open });
</script>
<style scoped lang="scss">
.service-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 16px;
margin-bottom: 20px;
background: var(--el-fill-color-extra-light);
}
.service-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
.el-button {
margin-left: 0;
}
}
.deployment-action {
min-height: 180px;
overflow-wrap: anywhere;
:deep(.content-container__main > .el-card) {
border: none;
box-shadow: none;
--el-card-padding: 0px;
}
}
</style>
@@ -2,7 +2,7 @@
<DrawerPro v-model="open" :header="headerTitle" size="large" @close="handleClose">
<template #content>
<div v-loading="loading">
<div class="toolbar">
<div v-if="!readOnly" class="toolbar">
<el-button v-permission type="primary" @click="openCreate">
{{ $t('commons.button.add') }}
</el-button>
@@ -10,7 +10,12 @@
<ComplexTable :data="items">
<el-table-column :label="$t('aiTools.model.model')" prop="id" min-width="220" />
<el-table-column :label="$t('commons.table.name')" prop="name" min-width="180" />
<fu-table-operations :buttons="buttons" :label="$t('commons.table.operate')" fixed="right" />
<fu-table-operations
v-if="!readOnly"
:buttons="buttons"
:label="$t('commons.table.operate')"
fixed="right"
/>
</ComplexTable>
</div>
</template>
@@ -60,6 +65,7 @@ import { Rules } from '@/global/form-rules';
const emit = defineEmits(['updated']);
const open = ref(false);
const readOnly = ref(false);
const editorOpen = ref(false);
const loading = ref(false);
const saving = ref(false);
@@ -207,6 +213,7 @@ const handleClose = () => {
};
const openDrawer = async (row: AI.AgentAccountItem) => {
readOnly.value = Boolean(row.appInstallId);
account.id = row.id;
account.name = row.name;
account.provider = row.provider;
+106
View File
@@ -0,0 +1,106 @@
import type { ReqPage, ResPage, ResultData } from '@/api/interface';
import type { AI } from '@/api/interface/ai';
import { pageTensorRTLLM, searchOllamaModel } from '@/api/modules/ai';
import { checkAppInstalled, searchAppInstalled } from '@/api/modules/app';
export interface Deployment {
id: number;
engine: 'vllm' | 'ollama' | 'tensorrt';
name: string;
version: string;
containerName: string;
modelDir: string;
models: string[];
status: string;
upgradable?: boolean;
}
interface VllmItem {
appInstallId: number;
path: string;
upgradable: boolean;
id: number;
servedModelName: string;
name: string;
appVersion: string;
port: number;
modelAccountBaseURL: string;
containerName: string;
modelDir: string;
status: string;
}
interface VllmAPI {
pageVllm: (req: ReqPage & { info: string }) => Promise<ResultData<ResPage<VllmItem>>>;
operateVllm: (req: { id: number; operate: string; taskID: string; forceDelete?: boolean }) => Promise<unknown>;
}
const vllmModules = import.meta.glob<VllmAPI>('@/xpack/api/modules/vllm.ts');
export const vllmAvailable = Object.keys(vllmModules).length > 0;
export async function loadVllmAPI() {
return Object.values(vllmModules)[0]();
}
export async function readPages<T>(search: (page: ReqPage) => Promise<ResultData<ResPage<T>>>) {
const items: T[] = [];
for (let page = 1; ; page++) {
const res = await search({ page, pageSize: 100 });
const batch = res.data.items || [];
items.push(...batch);
if (items.length >= res.data.total || batch.length === 0) {
return items;
}
}
}
export async function loadModelOverview(pro: boolean, fxplay: boolean) {
const [vllm, tensors, ollama] = await Promise.all([
pro && vllmAvailable
? loadVllmAPI().then((api) => readPages((page) => api.pageVllm({ ...page, info: '' })))
: Promise.resolve([]),
fxplay ? readPages<AI.TensorRTLLMDTO>((page) => pageTensorRTLLM({ ...page, name: '' })) : Promise.resolve([]),
checkAppInstalled('ollama', ''),
]);
const deployments: Deployment[] = vllm.map((item) => ({
id: item.id,
engine: 'vllm',
upgradable: item.upgradable,
name: item.name,
version: item.appVersion,
containerName: item.containerName,
modelDir: item.modelDir,
models: item.servedModelName ? [item.servedModelName] : [],
status: item.status,
}));
deployments.push(
...tensors.map((item): Deployment => ({
id: item.id!,
engine: 'tensorrt',
name: item.name,
version: item.version,
containerName: item.containerName,
modelDir: item.modelDir,
models: [],
status: item.status || '',
})),
);
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,
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,
});
}
return deployments;
}
+751 -21
View File
@@ -1,36 +1,766 @@
<template>
<div>
<RouterButton :buttons="buttons" />
<component :is="currentComponent" />
<div class="model-page">
<RouterButton :buttons="tabs" />
<component
:is="downloaderView"
v-if="isFiles && downloaderView"
@deploy="(modelDir) => createRef.open('vllm', modelDir)"
/>
<LayoutContent v-else-if="isFiles">
<template #main><el-empty :description="$t('license.levelUpPro')" /></template>
</LayoutContent>
<LocalView v-else-if="isManagement" />
<template v-else>
<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="openTemplates">
{{ $t('aiTools.model.deploymentTemplates') }}
</el-button>
</template>
<template #rightToolBar>
<el-select
v-if="!isLocal"
v-model="apiType"
clearable
filterable
placeholder=""
class="model-filter"
@change="resetPageAndSearch"
>
<template #prefix>{{ 'API ' + $t('commons.table.type') }}</template>
<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-model="provider"
clearable
filterable
class="model-filter"
placeholder=""
@change="resetPageAndSearch"
>
<template #prefix>{{ $t('aiTools.agents.provider') }}</template>
<el-option
v-for="item in providerOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<ProviderLabel :provider="item.value" :display-name="item.label" />
</el-option>
</el-select>
<TableSearch v-model:searchName="searchName" class="model-search" @search="resetPageAndSearch" />
<TableRefresh @search="search" />
<TableSetting v-if="isLocal" title="model-deployment-refresh" @search="!loading && search()" />
<fu-table-column-select
v-else
:columns="accountColumns"
trigger="hover"
:title="$t('commons.table.selectColumn')"
:only-icon="true"
/>
</template>
<template #main>
<el-alert
v-if="loadFailed"
type="error"
:closable="false"
:title="$t('aiTools.model.loadFailed')"
class="mb-4"
/>
<ComplexTable
v-if="!isLocal"
:data="connections"
row-key="id"
:pagination-config="pagination"
:columns="accountColumns"
local-key="modelServiceColumn"
@search="search"
>
<el-table-column prop="name" :label="$t('commons.table.name')" min-width="220" fixed="left">
<template #default="{ row }">
<div class="service-name">
<div class="service-title cell-ellipsis" :title="row.name">{{ row.name }}</div>
<el-tag class="service-source-tag" type="info" size="small">
{{
$t(
!row.appInstallId
? 'aiTools.model.cloudAccount'
: 'aiTools.model.localModel',
)
}}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="provider" :label="$t('aiTools.model.engineProvider')" min-width="180">
<template #default="{ row }">
<div class="cell-ellipsis">
<ProviderLabel :provider="row.provider" :display-name="row.providerName" />
</div>
</template>
</el-table-column>
<el-table-column
prop="apiType"
:label="'API ' + $t('commons.table.type')"
min-width="200"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.apiType || '—' }}
</template>
</el-table-column>
<el-table-column prop="models" :label="$t('aiTools.model.model')" min-width="200">
<template #default="{ row }">
<div
v-for="(model, index) in (row.models || []).slice(0, 2)"
:key="model.id"
class="model-line"
>
<span class="cell-ellipsis" :title="model.id">{{ model.id }}</span>
<el-button
v-if="index === 1 && row.models.length > 2"
link
type="primary"
class="model-more"
@click="poolRef.open(row)"
>
+{{ row.models.length - 2 }}
</el-button>
</div>
<span v-if="!row.models?.length">—</span>
</template>
</el-table-column>
<el-table-column prop="verified" :label="$t('aiTools.model.connectionStatus')" min-width="130">
<template #default="{ row }">
<el-tag :type="connectionStatus(row).type">
{{ connectionStatus(row).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="baseUrl"
:label="$t('aiTools.model.endpoint')"
min-width="280"
show-overflow-tooltip
>
<template #default="{ row }">
{{ row.baseUrl || '—' }}
</template>
</el-table-column>
<el-table-column
prop="createdAt"
:label="$t('commons.table.createdAt')"
:formatter="dateFormat"
min-width="180"
show-overflow-tooltip
/>
<fu-table-operations
:buttons="accountButtons"
:label="$t('commons.table.operate')"
fixed="right"
:ellipsis="3"
width="220"
/>
</ComplexTable>
<ComplexTable
v-else
:data="deploymentPage"
:pagination-config="pagination"
@search="resetPageIfNeeded"
>
<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)">
{{ row.name }}
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('app.version')" min-width="150">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span>{{ row.version || '—' }}</span>
<el-button
v-if="row.upgradable && hasPermission('app_manage')"
v-permission
link
type="primary"
class="shrink-0"
:disabled="operationDisabled(row)"
@click="manageRef.open(row, 'upgrade', true)"
>
{{ $t('commons.button.upgrade') }}
</el-button>
</div>
</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"
link
type="primary"
class="model-more"
@click="manageDeployment(row)"
>
+{{ row.models.length - 2 }}
</el-button>
</div>
<span v-if="!row.models.length">—</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="150">
<template #default="{ row }">
<el-dropdown placement="bottom" :disabled="statusDisabled(row)">
<Status v-permission :status="row.status" operate :disabled="statusDisabled(row)" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
:disabled="statusDisabled(row) || row.status === 'Running'"
@click="operate(row, 'start')"
>
{{ $t('commons.operate.start') }}
</el-dropdown-item>
<el-dropdown-item
:disabled="
statusDisabled(row) || ['Stopped', 'Exited'].includes(row.status)
"
@click="operate(row, 'stop')"
>
{{ $t('commons.operate.stop') }}
</el-dropdown-item>
<el-dropdown-item
:disabled="statusDisabled(row)"
@click="operate(row, 'restart')"
>
{{ $t('commons.button.restart') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
<el-table-column :label="$t('container.source')" min-width="170">
<template #default="{ row }">
<template v-if="row.status === 'Running' && row.containerName">
<ContainerResource
v-if="resourceStats[row.containerName]"
:stats="resourceStats[row.containerName]"
/>
<el-icon
v-else-if="resourceStats[row.containerName] === undefined"
class="is-loading"
>
<Loading />
</el-icon>
<el-tooltip v-else :content="$t('aiTools.model.loadFailed')">
<span>—</span>
</el-tooltip>
</template>
<span v-else>—</span>
</template>
</el-table-column>
<fu-table-operations
:buttons="deploymentButtons"
:label="$t('commons.table.operate')"
fixed="right"
:ellipsis="4"
width="240"
/>
</ComplexTable>
</template>
</LayoutContent>
</template>
<CreateService
ref="createRef"
:engines-only="isLocal || isFiles"
:vllm-only="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" />
<component :is="templateView" v-if="templateView" ref="templateRef" />
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, reactive, ref, shallowRef, toRef, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessageBox } from 'element-plus';
import type { AI } from '@/api/interface/ai';
import type { Container } from '@/api/interface/container';
import { containerListStats } from '@/api/modules/container';
import ContainerResource from '@/components/container-resource/index.vue';
import type { FuTableColumnConfig } from '@/components/table/shared';
import { deleteAgentAccount, getAgentProviders, pageAgentAccounts, operateTensorRTLLM } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { getAgentProviderDisplayName, isAgentAccountVerificationSkipped } from '@/utils/agent';
import { loadOptionalComponent } from '@/extensions/optional';
import i18n from '@/lang';
import AccountView from '@/views/ai/agents/model/index.vue';
import LocalView from '@/views/ai/model/local/index.vue';
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 TaskLog from '@/components/log/task/index.vue';
import LocalView from './local/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 ProviderLabel from '@/components/agent-provider-label/index.vue';
import { loadModelOverview, loadVllmAPI, readPages, vllmAvailable, type Deployment } from './deployment';
const route = useRoute();
const buttons = computed<RouterButton[]>(() => {
return [
const router = useRouter();
const pagePath = route.path;
const isCurrentPage = computed(() => route.path === pagePath);
const { isProductPro, isFxplay, isIntl, currentNode } = useGlobalStore();
const isFiles = pagePath === '/ai/model/files';
const isLocal = pagePath === '/ai/model/local';
const isManagement = computed(
() => isCurrentPage.value && isLocal && ['vllm', 'tensorrt'].includes(String(route.query.tab)),
);
const tabs = computed(() => [
{ label: i18n.global.t('aiTools.model.services'), path: '/ai/model/account' },
{ label: i18n.global.t('aiTools.model.files'), path: '/ai/model/files' },
{ label: i18n.global.t('aiTools.model.engines'), path: '/ai/model/local' },
]);
const downloaderModules = import.meta.glob('/src/xpack/views/model-downloader/index.vue');
const downloaderView = Object.keys(downloaderModules).length
? defineAsyncComponent(() => loadOptionalComponent('/src/xpack/views/model-downloader/index.vue'))
: null;
const createRef = ref();
const manageRef = ref();
const upgradeRef = ref();
const appDeleteRef = ref();
const appResourcesRef = ref();
const poolRef = ref();
const templateRef = ref();
const templateView = shallowRef();
const loading = ref(false);
const loadFailed = ref(false);
const connections = ref<AI.AgentAccountItem[]>([]);
const accountColumns = ref<FuTableColumnConfig[]>([]);
const providers = ref<AI.ProviderInfo[]>([]);
watch(
currentNode,
async (_, __, onCleanup) => {
if (isLocal || isFiles) return;
let cancelled = false;
onCleanup(() => (cancelled = true));
providers.value = [];
try {
const { data } = await getAgentProviders();
if (!cancelled) providers.value = data || [];
} catch {
if (!cancelled) providers.value = [];
}
},
{ immediate: true },
);
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 });
if (!isLocal && !isFiles) {
useSearchState(
'ai/agents/model/index',
{
label: i18n.global.t('aiTools.agents.account'),
path: '/ai/model/account',
search: searchName,
apiType,
provider,
page: toRef(pagination, 'currentPage'),
pageSize: toRef(pagination, 'pageSize'),
},
{
label: i18n.global.t('aiTools.model.localModel'),
path: '/ai/model/local',
onRevisit: () => {
if (typeof route.query.name === 'string' && route.query.name !== searchName.value) {
searchName.value = route.query.name;
resetPage();
}
return search();
},
},
];
);
}
const apiTypeOptions = computed(() =>
Array.from(new Set(providers.value.flatMap((item) => item.apiTypes.map((api) => api.apiType)))).sort(),
);
const providerOptions = computed(() =>
providers.value
.filter((item) => !isIntl.value || !['ark-coding-plan', 'bailian-coding-plan'].includes(item.provider))
.map((item) => ({
value: item.provider,
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, isManagement, currentNode],
async ([rows, active, management, node], _, onCleanup) => {
resourceStats.value = {};
const controller = new AbortController();
onCleanup(() => {
controller.abort();
});
if (!active || !isLocal || management) return;
const names = [
...new Set(
rows.filter((row) => row.status === 'Running' && row.containerName).map((row) => row.containerName),
),
];
if (!names.length) return;
try {
const { data } = await containerListStats(node, undefined, {
signal: controller.signal,
skipErrorMessage: true,
});
if (controller.signal.aborted) return;
const statsByName = new Map((data || []).map((item) => [item.containerName, item]));
resourceStats.value = Object.fromEntries(names.map((name) => [name, statsByName.get(name) || null]));
} catch {
if (!controller.signal.aborted) resourceStats.value = Object.fromEntries(names.map((name) => [name, null]));
}
},
{ immediate: true },
);
const resetPage = () => {
pagination.currentPage = 1;
};
const resetPageAndSearch = () => {
resetPage();
search();
};
const resetPageIfNeeded = () => {
pagination.currentPage = Math.min(
pagination.currentPage,
Math.max(1, Math.ceil(pagination.total / pagination.pageSize)),
);
};
watch(filteredDeployments, () => {
if (!isLocal) return;
pagination.total = filteredDeployments.value.length;
resetPageIfNeeded();
});
const currentComponent = computed(() => {
if (route.path === '/ai/model/local') {
return LocalView;
let searchVersion = 0;
const search = async () => {
const version = ++searchVersion;
if (!isCurrentPage.value || isFiles || isManagement.value) return;
loading.value = true;
loadFailed.value = false;
try {
if (isLocal) {
const data = await loadModelOverview(isProductPro.value, isFxplay.value);
if (version !== searchVersion) return;
deployments.value = data;
} else {
const result = await pageAgentAccounts({
page: pagination.currentPage,
pageSize: pagination.pageSize,
apiType: apiType.value,
provider: provider.value,
name: searchName.value,
});
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();
}
} catch {
if (version !== searchVersion) return;
connections.value = [];
pagination.total = 0;
deployments.value = [];
loadFailed.value = true;
} finally {
if (version === searchVersion) loading.value = false;
}
return AccountView;
});
};
const connectionStatus = (row: AI.AgentAccountItem) => {
if (isAgentAccountVerificationSkipped(row.provider)) {
return { type: 'info' as const, label: i18n.global.t('aiTools.agents.verifySkipped') };
}
return {
type: row.verified ? ('success' as const) : ('warning' as const),
label: i18n.global.t(row.verified ? 'aiTools.agents.verified' : 'aiTools.model.unverified'),
};
};
const manageDeployment = (row: Deployment) => manageRef.value.open(row);
const accountButtons = [
{
label: i18n.global.t('commons.button.edit'),
permission: true,
click: (row: AI.AgentAccountItem) => createRef.value.openAccount(row),
},
{
label: i18n.global.t('aiTools.agents.modelPool'),
click: (row: AI.AgentAccountItem) => poolRef.value.open(row),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: AI.AgentAccountItem) => !row.appInstallId || row.provider === 'ollama',
click: async (row: AI.AgentAccountItem) => {
await ElMessageBox.confirm(
i18n.global.t('commons.msg.delete', [row.name]),
i18n.global.t('commons.button.delete'),
{ type: 'warning' },
);
await deleteAgentAccount({ id: row.id });
await search();
},
},
];
const taskRef = ref();
const pending = ref('');
const operationDisabled = (row: Deployment) =>
Boolean(pending.value) ||
['Installing', 'Starting', 'ReStarting', 'Restarting', 'Waiting', 'Uninstalling'].includes(row.status);
const statusDisabled = (row: Deployment) =>
operationDisabled(row) || !hasManagePermissionAccess() || (row.engine === 'ollama' && !hasPermission('app_manage'));
const operate = async (row: Deployment, operation: 'start' | 'stop' | 'restart') => {
if (statusDisabled(row)) return;
const node = currentNode.value;
await ElMessageBox.confirm(
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operation)]),
i18n.global.t('commons.operate.' + operation),
{ type: 'warning' },
);
if (node !== currentNode.value || statusDisabled(row)) return;
pending.value = `${row.engine}:${row.id}`;
try {
if (row.engine === 'ollama') {
await installedOp({ installId: row.id, operate: operation });
} else if (row.engine === 'tensorrt') {
await operateTensorRTLLM({ id: row.id, operate: operation });
} else {
const taskID = newUUID();
await (await loadVllmAPI()).operateVllm({ id: row.id, operate: operation, taskID });
if (node !== currentNode.value) return;
if (row.version.toLowerCase().startsWith('nvidia-gb10-dspark-')) {
await search();
taskRef.value.openWithTaskID(taskID);
return;
}
}
if (node !== currentNode.value) return;
await search();
} finally {
pending.value = '';
}
};
const deploymentButtons = [
{
label: i18n.global.t('aiTools.model.manage'),
show: (row: Deployment) => row.engine !== 'vllm',
click: manageDeployment,
},
{
label: i18n.global.t('commons.button.edit'),
permission: true,
show: (row: Deployment) => row.engine === 'vllm',
disabled: operationDisabled,
click: (row: Deployment) => manageRef.value.open(row, 'config', true),
},
{
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('commons.button.log'),
click: (row: Deployment) => manageRef.value.open(row, 'log', true),
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
show: (row: Deployment) => row.engine === 'ollama' && hasPermission('app_manage'),
disabled: operationDisabled,
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) && row.status !== 'Uninstalling',
click: (row: Deployment) => manageRef.value.open(row, 'delete', true),
},
];
const openTemplates = async () => {
templateView.value = await loadOptionalComponent('/src/xpack/views/vllm/template/index.vue');
await nextTick();
templateRef.value.acceptParams();
};
watch(
[
() => route.path,
() => route.query.tab,
() => route.query.name,
() => route.query.open,
currentNode,
isProductPro,
isFxplay,
],
async (_, previous, onCleanup) => {
let cancelled = false;
onCleanup(() => (cancelled = true));
if (!isCurrentPage.value) {
searchVersion++;
loading.value = false;
return;
}
if (isLocal && route.query.tab === 'downloader') {
await router.replace('/ai/model/files');
return;
}
if (isLocal || isFiles) {
apiType.value = '';
engine.value = '';
provider.value = '';
searchName.value = '';
resetPage();
} else {
if (previous.length && previous[0] !== pagePath) return;
if (typeof route.query.name === 'string' && route.query.name !== searchName.value) {
searchName.value = route.query.name;
resetPage();
}
}
await search();
if (cancelled) return;
if (
isCurrentPage.value &&
isLocal &&
route.query.open === 'create' &&
(route.query.engine === 'ollama' || route.query.tab === 'ollama')
) {
await nextTick();
if (cancelled) return;
await createRef.value.open('ollama');
if (cancelled) return;
const query = { ...route.query };
delete query.open;
delete query.tab;
await router.replace({ path: route.path, query });
}
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
.service-name {
min-width: 0;
}
.service-title {
font-weight: 600;
}
.service-source-tag {
margin-top: 4px;
}
.cell-ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.model-line {
display: flex;
align-items: center;
& + & {
margin-top: 6px;
}
}
.model-more {
flex-shrink: 0;
margin-left: 8px;
}
.model-filter {
width: 220px;
}
.model-search {
width: 220px;
:deep(.search-button) {
width: 100%;
}
}
@media (max-width: 768px) {
.model-filter,
.model-search {
width: 100%;
}
}
</style>
+11 -110
View File
@@ -1,134 +1,35 @@
<template>
<div>
<div class="content-container__search">
<el-card>
<div>
<el-button
v-for="button in buttons"
:key="button.value"
class="tag-button"
:class="currentTab === button.value ? '' : 'no-active'"
:type="currentTab === button.value ? 'primary' : ''"
@click="handleChange(button.value)"
>
{{ button.label }}
</el-button>
</div>
</el-card>
</div>
<div class="local-model-content">
<component :is="currentComponent" />
</div>
<el-button class="mt-4" icon="Back" @click="router.push('/ai/model/local')">
{{ $t('aiTools.model.engines') }}
</el-button>
<component :is="currentComponent" />
</div>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import OllamaView from '@/views/ai/model/ollama/index.vue';
import TensorRTView from '@/views/ai/model/tensorrt/index.vue';
import VllmPlaceholder from '@/views/ai/model/vllm/index.vue';
import { loadOptionalComponent } from '@/extensions/optional';
import i18n from '@/lang';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { vllmAvailable } from '../deployment';
const VllmView = defineAsyncComponent(() => loadOptionalComponent('/src/xpack/views/vllm/index.vue'));
const ModelDownloaderView = defineAsyncComponent(() =>
loadOptionalComponent('/src/xpack/views/model-downloader/index.vue'),
);
type LocalTab = 'ollama' | 'vllm' | 'tensorrt' | 'downloader';
const route = useRoute();
const router = useRouter();
const { isFxplay, isProductPro } = useGlobalStore();
const tabLabels: Record<LocalTab, string> = {
ollama: 'Ollama',
vllm: 'vLLM',
tensorrt: 'TensorRT LLM',
downloader: i18n.global.t('aiTools.model.downloader'),
};
const buttons = computed<Array<{ label: string; value: LocalTab }>>(() => {
const items: Array<{ label: string; value: LocalTab }> = [
{ label: tabLabels.vllm, value: 'vllm' },
{ label: tabLabels.ollama, value: 'ollama' },
];
if (isFxplay.value) {
items.push({ label: tabLabels.tensorrt, value: 'tensorrt' });
}
items.push({ label: tabLabels.downloader, value: 'downloader' });
return items;
});
const currentTab = computed<LocalTab>(() => {
const tab = route.query.tab;
if (tab === 'ollama' || tab === 'vllm' || tab === 'downloader' || (tab === 'tensorrt' && isFxplay.value)) {
return tab;
}
if (tab === 'tensorrt') {
return 'ollama';
}
return isProductPro.value ? 'vllm' : 'ollama';
});
const currentComponent = computed(() => {
switch (currentTab.value) {
case 'vllm':
return VllmView;
case 'tensorrt':
return TensorRTView;
case 'downloader':
return ModelDownloaderView;
default:
return OllamaView;
}
if (route.query.tab === 'vllm') return isProductPro.value && vllmAvailable ? VllmView : VllmPlaceholder;
if (route.query.tab === 'tensorrt' && isFxplay.value) return TensorRTView;
return VllmPlaceholder;
});
const handleChange = async (target: LocalTab) => {
await router.replace({
path: '/ai/model/local',
query: {
...route.query,
tab: target,
},
});
};
watch(
[() => route.query.tab, isFxplay],
async ([tab, fxplay]) => {
if (tab !== 'tensorrt' || fxplay) {
return;
}
await router.replace({
path: '/ai/model/local',
query: {
...route.query,
tab: 'ollama',
},
});
async () => {
if (route.query.tab === 'tensorrt' && !isFxplay.value) await router.replace('/ai/model/local');
},
{ immediate: true },
);
</script>
<style lang="scss" scoped>
.content-container__search {
margin-top: 7px;
:deep(.el-card) {
--el-card-padding: 12px;
}
}
.local-model-content {
:deep(.content-container__app) {
margin-top: 7px;
}
:deep(.content-container__main) {
margin-top: 7px;
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('commons.button.add')" @close="handleClose">
<DialogPro size="normal" v-model="drawerVisible" :title="$t('commons.button.add')" @close="handleClose">
<el-alert type="info" :closable="false">
<template #title>
<span class="flx-align-center">
@@ -32,7 +32,7 @@
</el-button>
</span>
</template>
</DrawerPro>
</DialogPro>
</template>
<script lang="ts" setup>
@@ -1,145 +0,0 @@
<template>
<DrawerPro v-model="drawerVisible" :header="$t('database.databaseConnInfo')" @close="handleClose" size="small">
<el-form @submit.prevent v-loading="loading" :model="form" label-position="top">
<el-row type="flex" justify="center">
<el-col :span="22">
<el-form-item :label="$t('database.containerConn')">
<el-card class="mini-border-card">
<el-descriptions :column="1">
<el-descriptions-item :label="$t('database.connAddress')">
{{ form.containerName }}
<CopyButton :content="form.containerName" />
</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.port')">
11434
<CopyButton content="11434" />
</el-descriptions-item>
</el-descriptions>
</el-card>
<span class="input-help">
{{ $t('aiTools.model.container_conn_helper') }}
</span>
</el-form-item>
<el-form-item :label="$t('setting.proxyUrl')" v-if="bindDomain.connUrl != ''">
<el-card class="mini-border-card">
<el-descriptions :column="1">
<el-descriptions-item :label="$t('database.connAddress')">
{{ bindDomain.connUrl }}
<CopyButton :content="bindDomain.connUrl" />
</el-descriptions-item>
</el-descriptions>
</el-card>
<span class="input-help">
{{ $t('database.remoteConnHelper2') }}
</span>
</el-form-item>
<el-form-item :label="$t('database.remoteConn')" v-else>
<el-card class="mini-border-card">
<el-descriptions :column="1">
<el-descriptions-item :label="$t('database.connAddress')">
{{ form.systemIP }}
<CopyButton :content="form.systemIP" />
</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.port')">
{{ form.port }}
<CopyButton :content="form.port + ''" />
</el-descriptions-item>
</el-descriptions>
</el-card>
<span class="input-help">
{{ $t('database.remoteConnHelper2') }}
</span>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="drawerVisible = false">
{{ $t('commons.button.cancel') }}
</el-button>
</span>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import i18n from '@/lang';
import { ElForm } from 'element-plus';
import { getAgentSettingInfo } from '@/api/modules/setting';
import { getBindDomain } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { currentNode } = useGlobalStore();
const loading = ref(false);
const drawerVisible = ref(false);
const form = reactive({
systemIP: '',
containerName: '',
port: 0,
remoteIP: '',
});
const bindDomain = ref({
connUrl: '',
});
interface DialogProps {
port: number;
containerName: string;
appinstallID: number;
}
const acceptParams = (param: DialogProps): void => {
form.containerName = param.containerName;
form.port = param.port;
loadSystemIP();
loadBindDomain(param.appinstallID);
drawerVisible.value = true;
};
const handleClose = () => {
drawerVisible.value = false;
};
const loadSystemIP = async () => {
if (currentNode.value !== 'local') {
form.systemIP = currentNode.value || i18n.global.t('database.localIP');
return;
}
const res = await getAgentSettingInfo();
form.systemIP = res.data.systemIP || i18n.global.t('database.localIP');
};
const loadBindDomain = async (appInstallID: number) => {
if (appInstallID == undefined || appInstallID <= 0) {
return;
}
try {
const res = await getBindDomain({
appInstallID: appInstallID,
});
if (res.data.websiteID > 0) {
bindDomain.value.connUrl = res.data.connUrl;
}
} catch (e) {}
};
defineExpose({
acceptParams,
});
</script>
<style lang="scss" scoped>
.copy_button {
border-radius: 0px;
border-left-width: 0px;
}
:deep(.el-input__wrapper) {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
</style>
@@ -1,5 +1,5 @@
<template>
<DrawerPro v-model="open" :header="$t('aiTools.proxy.proxy')" @close="handleClose" size="large">
<DialogPro v-model="open" :title="$t('aiTools.proxy.proxy')" @close="handleClose" size="large">
<div v-loading="loading">
<el-form ref="formRef" label-position="top" @submit.prevent :model="req" :rules="rules">
<el-alert class="common-prompt" :closable="false" type="warning">
@@ -89,7 +89,7 @@
{{ $t('commons.button.add') }}
</el-button>
</template>
</DrawerPro>
</DialogPro>
</template>
<script lang="ts" setup>
@@ -1,67 +1,28 @@
<template>
<div v-loading="loading">
<el-button v-permission :disabled="modelInfo.status !== 'Running'" @click="bindDomain">
{{ $t('aiTools.proxy.proxy') }}
</el-button>
<el-divider />
<div class="model-section-heading">
<h3 class="section-title">{{ $t('aiTools.model.modelManagement') }}</h3>
</div>
<LayoutContent title="Ollama">
<template #app>
<AppStatus
app-key="ollama"
v-model:loading="loading"
:hide-setting="true"
v-model:mask-show="maskShow"
v-model:appInstallID="appInstallID"
@is-exist="checkExist"
ref="appStatusRef"
></AppStatus>
</template>
<template #prompt>
<el-alert type="info" :closable="false">
<template #title>
<span>{{ $t('runtime.systemRestartHelper') }}</span>
</template>
</el-alert>
</template>
<template #leftToolBar>
<el-button v-permission :disabled="modelInfo.status !== 'Running'" type="primary" @click="onCreate()">
<el-button v-permission :disabled="modelInfo.status !== 'Running'" type="primary" @click="onCreate">
{{ $t('commons.button.add') }}
</el-button>
<el-button
v-permission
plain
type="primary"
:disabled="modelInfo.status !== 'Running'"
@click="bindDomain"
>
{{ $t('aiTools.proxy.proxy') }}
</el-button>
<el-button :disabled="modelInfo.status !== 'Running'" @click="onLoadConn" type="primary" plain>
{{ $t('database.databaseConnInfo') }}
</el-button>
<el-button
v-permission
:disabled="modelInfo.status !== 'Running'"
type="primary"
plain
@click="onSync()"
>
<el-button v-permission :disabled="modelInfo.status !== 'Running'" @click="onSync">
{{ $t('database.loadFromRemote') }}
</el-button>
<el-button
:disabled="modelInfo.status !== 'Running'"
icon="Position"
@click="goDashboard()"
type="primary"
plain
>
OpenWebUI
</el-button>
<el-button v-permission plain :disabled="selects.length === 0" type="primary" @click="onDelete(null)">
<el-button v-permission :disabled="selects.length === 0" @click="onDelete(null)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
<template #rightToolBar>
<TableSearch @search="search()" v-model:searchName="searchName" />
<TableRefresh @search="search()" />
<TableSetting title="model-refresh" @search="search()" />
<TableSearch @search="search" v-model:searchName="searchName" />
<TableRefresh @search="search" />
<TableSetting title="model-refresh" @search="!loading && search()" />
</template>
<template #main>
<ComplexTable
@@ -69,15 +30,19 @@
row-key="id"
:pagination-config="paginationConfig"
v-model:selects="selects"
:class="{ mask: maskShow }"
@sort-change="search"
@search="search"
:data="data"
>
<el-table-column type="selection" :selectable="selectable" fix />
<el-table-column :label="$t('aiTools.model.model')" prop="name" min-width="90">
<el-table-column :label="$t('aiTools.model.model')" prop="name" min-width="180">
<template #default="{ row }">
<el-text v-if="row.size" type="primary" class="cursor-pointer" @click="onLoad(row.name)">
<el-text
v-if="row.size && modelInfo.status === 'Running'"
type="primary"
class="cursor-pointer"
@click="onLoad(row.name)"
>
{{ row.name }}
</el-text>
<span v-else>{{ row.name }}</span>
@@ -110,57 +75,24 @@
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('commons.button.log')">
<template #default="{ row }">
<el-button @click="onLoadLog(row)" link type="primary">
{{ $t('website.check') }}
</el-button>
</template>
</el-table-column>
<el-table-column
min-width="80"
min-width="170"
:label="$t('commons.table.date')"
prop="createdAt"
:formatter="dateFormat"
/>
<fu-table-operations
:ellipsis="isMobile ? 0 : 10"
:min-width="isMobile ? 'auto' : 200"
:ellipsis="isMobile ? 0 : 1"
:width="isMobile ? 'auto' : 110"
:buttons="buttons"
:label="$t('commons.table.operate')"
fixed="right"
fix
/>
</ComplexTable>
<el-card v-if="modelInfo.status != 'Running' && !loading && maskShow" class="mask-prompt">
<span v-if="modelInfo.isExist">
{{ $t('commons.service.serviceNotStarted', ['Ollama']) }}
</span>
<span v-else>
{{ $t('app.checkInstalledWarn', ['Ollama']) }}
<el-button @click="goInstall('ollama')" link icon="Position" type="primary">
{{ $t('database.goInstall') }}
</el-button>
</span>
</el-card>
</template>
</LayoutContent>
<DialogPro v-model="dashboardVisible" :title="$t('app.checkTitle')" size="mini">
<div class="flex justify-center items-center gap-2 flex-wrap">
{{ $t('app.checkInstalledWarn', ['OpenWebUI']) }}
<el-link icon="Position" @click="goInstall('ollama-webui')" type="primary">
{{ $t('database.goInstall') }}
</el-link>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="dashboardVisible = false">{{ $t('commons.button.cancel') }}</el-button>
</span>
</template>
</DialogPro>
<OpDialog ref="opRef" @search="search" @submit="onSubmitDelete()">
<template #content>
<el-form class="mt-4 mb-1" ref="deleteForm" label-position="left">
@@ -176,28 +108,22 @@
<AddDialog ref="addRef" @search="search" @log="onLoadLog" />
<Del ref="delRef" @search="search" />
<Terminal ref="terminalRef" />
<Conn ref="connRef" />
<CodemirrorDrawer ref="detailRef" />
<PortJumpDialog ref="dialogPortJumpRef" />
<DialogPro v-model="detailVisible" :title="$t('commons.button.view')" size="large">
<CodemirrorPro v-model="detailInfo" :height-diff="300" readonly mode="json" />
</DialogPro>
<BindDomain ref="bindDomainRef" />
<TaskLog ref="taskLogRef" width="70%" @close="search" />
<TaskLog ref="taskLogRef" width="70%" @close="refresh" />
</div>
</template>
<script lang="ts" setup>
import { useSearchState } from '@/utils/search-reset';
import AppStatus from '@/components/app-status/index.vue';
import AddDialog from '@/views/ai/model/ollama/add/index.vue';
import Conn from '@/views/ai/model/ollama/conn/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import Terminal from '@/views/ai/model/ollama/terminal/index.vue';
import Del from '@/views/ai/model/ollama/del/index.vue';
import PortJumpDialog from '@/components/port-jump/index.vue';
import CodemirrorDrawer from '@/components/codemirror-pro/drawer.vue';
import { toRef, onMounted, reactive, ref } from 'vue';
import i18n from '@/lang';
import { App } from '@/api/interface/app';
import {
deleteOllamaModel,
loadOllamaModel,
@@ -206,27 +132,22 @@ import {
syncOllamaModel,
} from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import { getAppPort } from '@/api/modules/app';
import { checkAppInstalled } from '@/api/modules/app';
import { dateFormat } from '@/utils/date';
import { newUUID } from '@/utils/id';
import { MsgInfo, MsgSuccess } from '@/utils/message';
import BindDomain from '@/views/ai/model/ollama/domain/index.vue';
import { routerToNameWithQuery } from '@/utils/router';
import { useGlobalStore } from '@/composables/useGlobalStore';
const props = defineProps({ instanceName: String });
const { isMobile } = useGlobalStore();
const loading = ref(false);
const selects = ref<any>([]);
const maskShow = ref(false);
const addRef = ref();
const detailRef = ref();
const detailVisible = ref(false);
const detailInfo = ref('');
const delRef = ref();
const connRef = ref();
const terminalRef = ref();
const openWebUIPort = ref();
const dashboardVisible = ref(false);
const dialogPortJumpRef = ref();
const appStatusRef = ref();
const bindDomainRef = ref();
const taskLogRef = ref();
const data = ref();
@@ -247,13 +168,7 @@ const opRef = ref();
const operateIDs = ref();
const forceDelete = ref();
const modelInfo = reactive({
status: '',
container: '',
isExist: null,
version: '',
port: 11434,
});
const modelInfo = reactive({ status: '' });
function selectable(row) {
return row.status !== 'Waiting';
@@ -286,7 +201,7 @@ const onSync = async () => {
await syncOllamaModel()
.then((res) => {
loading.value = false;
if (res.data) {
if (res.data?.length) {
delRef.value.acceptParams({ list: res.data });
} else {
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
@@ -298,58 +213,16 @@ const onSync = async () => {
});
};
const onLoadConn = async () => {
connRef.value.acceptParams({
port: modelInfo.port,
containerName: modelInfo.container,
appinstallID: appInstallID.value,
});
};
const onLoad = async (name: string) => {
const res = await loadOllamaModel(name);
let detailInfo = res.data;
let param = {
header: i18n.global.t('commons.button.view'),
detailInfo: detailInfo,
mode: 'json',
};
detailRef.value!.acceptParams(param);
};
const goDashboard = async () => {
if (openWebUIPort.value === 0) {
dashboardVisible.value = true;
return;
}
dialogPortJumpRef.value.acceptParams({ port: openWebUIPort.value });
detailInfo.value = res.data;
detailVisible.value = true;
};
const bindDomain = () => {
bindDomainRef.value.acceptParams(appInstallID.value);
};
const goInstall = (name: string) => {
routerToNameWithQuery('AppAll', { install: name });
};
const loadWebUIPort = async () => {
const res = await getAppPort('ollama-webui', '');
openWebUIPort.value = res.data;
};
const checkExist = (data: App.CheckInstalled) => {
modelInfo.isExist = data.isExist;
modelInfo.status = data.status;
modelInfo.version = data.version;
modelInfo.container = data.containerName;
modelInfo.port = data.httpPort;
if (modelInfo.isExist && modelInfo.status === 'Running') {
search();
}
};
const onSubmitDelete = async () => {
loading.value = true;
await deleteOllamaModel(operateIDs.value, forceDelete.value)
@@ -409,6 +282,7 @@ const onDelete = async (row: AI.OllamaModelInfo) => {
const onLoadLog = (row: any) => {
if (row.taskID) {
openTaskLog(row.taskID);
return;
}
if (row.from === 'remote') {
MsgInfo(i18n.global.t('aiTools.model.from_remote'));
@@ -418,6 +292,7 @@ const onLoadLog = (row: any) => {
MsgInfo(i18n.global.t('aiTools.model.no_logs'));
return;
}
taskLogRef.value.openWithResourceID('AI', 'TaskPull', row.id);
};
@@ -428,7 +303,7 @@ const buttons = [
terminalRef.value.acceptParams({ name: row.name });
},
disabled: (row: any) => {
return row.status !== 'Success';
return row.status !== 'Success' || modelInfo.status !== 'Running';
},
},
{
@@ -438,9 +313,13 @@ const buttons = [
onReCreate(row.name);
},
disabled: (row: any) => {
return row.status === 'Success' || row.status === 'Waiting';
return row.status === 'Success' || row.status === 'Waiting' || modelInfo.status !== 'Running';
},
},
{
label: i18n.global.t('commons.button.log'),
click: onLoadLog,
},
{
label: i18n.global.t('commons.button.delete'),
permission: true,
@@ -453,25 +332,23 @@ const buttons = [
},
];
onMounted(() => {
loadWebUIPort();
});
const refresh = async () => {
const { data } = await checkAppInstalled('ollama', props.instanceName || '');
appInstallID.value = data.appInstallId;
modelInfo.status = data.status;
await search();
};
onMounted(refresh);
</script>
<style lang="scss" scoped>
.iconInTable {
margin-left: 5px;
margin-top: 3px;
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.jumpAdd {
margin-top: 10px;
margin-left: 15px;
margin-bottom: 5px;
font-size: 12px;
}
.tagClass {
float: right;
font-size: 12px;
margin-top: 5px;
.model-section-heading {
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,113 @@
<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">
<template #after-params>
<AccountAddress
ref="accountRef"
:form="form"
:host-port="Number(form.params.PANEL_APP_PORT_HTTP) || 11434"
container-port="11434"
/>
</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 || !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';
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 form = ref({
appDetailId: 0,
name: '',
params: {} as Record<string, any>,
containerName: '',
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({
restartPolicy: 'unless-stopped',
modelAccountBaseURLType: 'container',
modelAccountBaseURL: '',
});
await formRef.value.initForm('ollama', true);
await nextTick();
formRef.value?.clearValidate();
} finally {
loading.value = false;
}
};
const submit = async () => {
if (!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) });
</script>
@@ -1,13 +1,6 @@
<template>
<DrawerPro
v-model="open"
:title="$t('menu.terminal')"
@close="handleClose"
:resource="title"
:autoClose="false"
:fullScreen="true"
>
<Terminal class="mt-2" style="height: calc(100vh - 175px)" ref="terminalRef"></Terminal>
<DialogPro v-model="open" :title="$t('menu.terminal')" size="w-70" @close="handleClose">
<Terminal class="mt-2" style="height: 55vh" ref="terminalRef"></Terminal>
<template #footer>
<span class="dialog-footer">
@@ -16,23 +9,26 @@
</el-button>
</span>
</template>
</DrawerPro>
</DialogPro>
</template>
<script lang="ts" setup>
import { nextTick, ref } from 'vue';
import { nextTick, onBeforeUnmount, ref } from 'vue';
import Terminal from '@/components/terminal/index.vue';
import { closeOllamaModel } from '@/api/modules/ai';
import { useGlobalStore } from '@/composables/useGlobalStore';
const title = ref();
const { currentNode } = useGlobalStore();
const open = ref(false);
const itemName = ref();
const terminalRef = ref();
let operateNode = '';
interface DialogProps {
name: string;
}
const acceptParams = async (params: DialogProps): Promise<void> => {
operateNode = currentNode.value;
itemName.value = params.name;
open.value = true;
initTerm();
@@ -40,7 +36,8 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
const initTerm = () => {
nextTick(() => {
terminalRef.value.acceptParams({
if (!open.value) return;
terminalRef.value?.acceptParams({
endpoint: '/api/v2/hosts/terminal/container',
args: `source=ollama&name=${itemName.value}`,
error: '',
@@ -50,7 +47,10 @@ const initTerm = () => {
};
const onClose = async () => {
await closeOllamaModel(itemName.value)
const name = itemName.value;
if (!name) return;
itemName.value = '';
await closeOllamaModel(name, operateNode)
.then(() => {
terminalRef.value?.onClose();
})
@@ -60,9 +60,10 @@ const onClose = async () => {
};
function handleClose() {
onClose();
open.value = false;
onClose();
}
onBeforeUnmount(handleClose);
defineExpose({
acceptParams,
@@ -78,6 +78,7 @@
<script lang="ts" setup>
import { toRef } from 'vue';
import { useRoute } from 'vue-router';
import { useSearchState } from '@/utils/search-reset';
import OperateDialog from './operate/index.vue';
import ComposeLogs from '@/components/log/compose/index.vue';
@@ -111,7 +112,8 @@ useSearchState(
},
{ onRevisit: () => search() },
);
const searchName = ref();
const route = useRoute();
const searchName = ref(typeof route.query.name === 'string' ? route.query.name : '');
const opRef = ref();
const dialogRef = ref();
const composeLogRef = ref();
@@ -1,5 +1,19 @@
<template>
<DrawerPro :header="$t('commons.button.' + mode)" v-model="drawerVisiable" size="large" @close="handleClose">
<component
:is="inline ? 'div' : dialog ? DialogPro : DrawerPro"
v-bind="
inline
? {}
: {
modelValue: drawerVisiable,
header: $t('commons.button.' + mode),
title: $t('commons.button.' + mode),
size: 'large',
}
"
@update:model-value="drawerVisiable = $event"
@close="handleClose"
>
<el-alert :title="$t('aiTools.tensorRT.imageAlert')" class="common-prompt" :closable="false" type="warning" />
<el-form ref="formRef" label-position="top" :model="tensorRTLLM" :rules="rules" v-loading="loading">
<el-form-item :label="$t('commons.table.name')" prop="name">
@@ -43,7 +57,7 @@
</el-form-item>
<NodeConfig v-model="tensorRTLLM" />
</el-form>
<template #footer>
<template v-if="!inline" #footer>
<span class="dialog-footer">
<el-button @click="drawerVisiable = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button v-permission :disabled="loading" type="primary" @click="onSubmit">
@@ -52,10 +66,12 @@
</span>
</template>
<FileList ref="modelDirRef" @choose="getModelDir" />
</DrawerPro>
</component>
</template>
<script lang="ts" setup>
import DialogPro from '@/components/dialog-pro/index.vue';
const props = defineProps({ dialog: Boolean, inline: Boolean });
import NodeConfig from '@/views/website/runtime//components/node-config.vue';
import DrawerPro from '@/components/drawer-pro/index.vue';
import FileList from '@/components/file-list/index.vue';
@@ -90,7 +106,7 @@ const newTensorRTLLM = () => {
};
const modelDirRef = ref();
const tensorRTLLM = ref(newTensorRTLLM());
const emit = defineEmits(['search']);
const emit = defineEmits(['search', 'close']);
const openCreate = (): void => {
mode.value = 'create';
@@ -119,6 +135,7 @@ const openEdit = (rowData: any): void => {
const handleClose = () => {
drawerVisiable.value = false;
emit('close');
};
const getModelDir = (path: string) => {
@@ -186,8 +203,14 @@ watch(
{ deep: true },
);
watch(drawerVisiable, (value) => {
if (props.inline && !value) handleClose();
});
defineExpose({
openCreate,
submit: onSubmit,
loading,
openEdit,
});
</script>
+3 -1
View File
@@ -258,7 +258,9 @@ onMounted(async () => {
key: installKey.value,
},
};
installRef.value.acceptParams(params);
if (!['ollama', 'vllm'].includes(installKey.value) || !jumpToInstall('', installKey.value)) {
installRef.value.acceptParams(params);
}
}
search(req);
if (isProductPro.value) {
@@ -48,6 +48,8 @@
:propStart="'params.'"
/>
<slot name="after-params" />
<el-form-item prop="advanced">
<el-checkbox v-model="formData.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item>
@@ -320,19 +322,23 @@ const loadInstallDefaultConfig = async () => {
}
};
const initForm = async (appKey: string) => {
const initForm = async (appKey: string, allowPort?: boolean) => {
operateNode.value = undefined;
env.value = undefined;
masterNodeAddr.value = undefined;
formData.value.name = appKey.replace(/^local/, '');
await loadInstallDefaultConfig();
if (allowPort === undefined) {
await loadInstallDefaultConfig();
} else {
formData.value.allowPort = allowPort;
}
const res = await getAppByKey(appKey);
currentApp.value = res.data;
appVersions.value = currentApp.value.versions;
if (appVersions.value.length > 0) {
const defaultVersion = appVersions.value[0];
formData.value.version = defaultVersion;
getVersionDetail(defaultVersion);
await getVersionDetail(defaultVersion);
}
if (isOffline.value) {
formData.value.pullImage = false;
@@ -178,72 +178,38 @@
>
<template #default="{ row }">
<span v-if="row.state !== 'running'">—</span>
<div v-else-if="row.hasLoad">
<div class="source-font">CPU: {{ row.cpuPercent.toFixed(2) }}%</div>
<div class="float-left source-font">
{{ $t('monitor.memory') }}: {{ row.memoryPercent.toFixed(2) }}%
</div>
<el-popover placement="right" width="500px" class="float-right">
<template #reference>
<svg-icon iconName="p-xiangqing" className="resource-detail-icon"></svg-icon>
<ContainerResource v-else-if="row.hasLoad" :stats="row">
<el-descriptions-item v-if="row.hasLoadSize">
<template #label>
{{ $t('container.sizeRw') }}
<el-tooltip :content="$t('container.sizeRwHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
<template #default>
<el-descriptions direction="vertical" border :column="3" size="small">
<el-descriptions-item :label="$t('container.cpuUsage')">
{{ computeCPU(row.cpuTotalUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.cpuTotal')">
{{ computeCPU(row.systemUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.core')">
{{ row.percpuUsage }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memUsage')">
{{ computeSizeForDocker(row.memoryUsage) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memCache')">
{{ computeSizeForDocker(row.memoryCache) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.memTotal')">
{{ computeSizeForDocker(row.memoryLimit) }}
</el-descriptions-item>
<el-descriptions-item v-if="row.hasLoadSize">
<template #label>
{{ $t('container.sizeRw') }}
<el-tooltip :content="$t('container.sizeRwHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRw) }}
</el-descriptions-item>
<el-descriptions-item
:label="$t('container.sizeRootFs')"
v-if="row.hasLoadSize"
>
<template #label>
{{ $t('container.sizeRootFs') }}
<el-tooltip :content="$t('container.sizeRootFsHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
{{ computeSize2(row.sizeRootFs) }}
</el-descriptions-item>
</el-descriptions>
<el-button
class="mt-2"
v-if="!row.hasLoadSize"
size="small"
link
type="primary"
@click="loadSize(row)"
>
{{ $t('container.loadSize') }}
</el-button>
{{ computeSize2(row.sizeRw) }}
</el-descriptions-item>
<el-descriptions-item :label="$t('container.sizeRootFs')" v-if="row.hasLoadSize">
<template #label>
{{ $t('container.sizeRootFs') }}
<el-tooltip :content="$t('container.sizeRootFsHelper')">
<el-icon class="icon-item"><InfoFilled /></el-icon>
</el-tooltip>
</template>
</el-popover>
</div>
{{ computeSize2(row.sizeRootFs) }}
</el-descriptions-item>
<template #footer>
<el-button
class="mt-2"
v-if="!row.hasLoadSize"
size="small"
link
type="primary"
@click="loadSize(row)"
>
{{ $t('container.loadSize') }}
</el-button>
</template>
</ContainerResource>
<div v-else>
<el-button link loading></el-button>
</div>
@@ -520,7 +486,8 @@ import { MsgSuccess, MsgWarning } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore';
import { routerToName, routerToNameWithQuery } from '@/utils/router';
import router from '@/routers';
import { computeSize2, computeSizeForDocker, computeCPU } from '@/utils/size';
import { computeSize2 } from '@/utils/size';
import ContainerResource from '@/components/container-resource/index.vue';
import { newUUID } from '@/utils/id';
import { updateCommonDescription } from '@/api/modules/setting';
@@ -789,7 +756,7 @@ const loadContainers = async (background = false, column?: any) => {
}
const ids = containers.filter((item) => item.state === 'running').map((item) => item.containerID);
if (ids.length > 0) {
const stats = await containerListStats(undefined, ids, controller.signal);
const stats = await containerListStats(undefined, ids, { signal: controller.signal });
if (!controller.signal.aborted) {
applyStatsToRows(stats.data || []);
}
@@ -1141,14 +1108,6 @@ const buttons = [
white-space: nowrap;
}
}
.source-font {
font-size: 12px;
}
.resource-detail-icon {
margin-top: -3px;
font-size: 6px;
cursor: pointer;
}
.tag-button {
margin-top: -5px;
margin-right: 10px;