Files
Yeuoly 888ad788bc refactor: plugin lifecycle control panel (#499)
* refactor: introduce local plugin control panel and cleanup environment setup process

* fix: args

* refactor: new local runtime

* temp: stash work for refactor on RemotePluginServer

* refactor: unify local runtime lifetime and sperate init environment process

* chore: add missing files

* stash

* refactor: local plugin lifetime control

* refactor: complete installation process of control panel

* refactor: adapt service layer to new controlpanel

* refactor: pluginManager.Install

* fix: add routine wrap to InstallServerless, avoid blocking main thread

* feat: reinstall serverless runtime

* chore: add comments to Reinstall and update confusing naming

* refactor: unify install plugin service

* refactor: add labels to debugging runtime

* refactor: add getters to plugin manager

* refactor: split install service to decode/install_task/install service

* ???

* refactor: adapt controllers

* refactor: session write

* refactor: session runtime

* Refine install task orchestration (#501)

* refactor: installing task

* refactor cluster management, decouple lifetime management and cluster

* fix cli test command

* fix: cleanup TODO comments and implement GracefulStop for instance

* feat: add logger to control panel

* fix: multiple nil references

* refactor: better lifetime control

* refactor: better cycle interval

* fix(LocalPluginRuntime): prevent returning err when it's not  error

* fix: avoid adding empty PipExtraArgs

* fix: missing errors in Environment init

* fix: add truncateMessage to avoid db explosion

* cleanup: better lifecycle management

* fix: init status at the beginning of installation

* optimize: GracefulStop for pluginInstance

* refactor: tests

* refactor: centralize routine labels (#504)

* cleanup: RoutineKey

* fix: init routine pool

* fix: correctly handle cluster register error

* fix: memory leak

* fix: add \n to instance write

* fix(installer.go): set success to true after succeed for defer func

* refactor

* fix: missing cwd in testutils

* fix: scaleup default runtime nums to 1 when testing

* fix: localruntime appconfig in testing module

* Update internal/core/local_runtime/load_balancing.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix: more efficiency implement in installer_local.go

* fix: returns after failing in onDebuggingRuntimeDisconnected

* fix: returns after failing in onDebuggingRuntimeDisconnected

* fix: splits tests

* refactor: naming

* refactor: manifest.VersionX

* fix: adapt SetDefault to tests

* fix: enforce use constants in DBType

* fix: generate

* fix: linter

* cleanup tests

* refactor: change  package to

* cleanup: useless codes

* Update internal/cluster/plugin.go

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* cleanup

* refactor: decouple connection_key management from debugging_time

* refactor: confused naming

* feat: recycle resources to adapt to https://github.com/langgenius/dify-plugin-daemon/pull/500

* refactor: confusing redirecting

* fix: support get serverless runtime

* fix: race condition in Launching

* fix: avoid ManifestValidate in first step of debugging handshake

* fix: adding ReleaseAllLocks to finalizers

* wtf: what a beautiful code

* refactor: rename Stream.Async to Stream.Process

* fix: kill process if daed instance was detected

* fix: correctly handle failures

* fix: consistence of difference interfaces

* fix: add stacktrace to panic

* fix: only trigger once  event

* fix: ensure plugin runtime was shutdown

* feat: cleanup install tasks

* fix: add scale logs

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-11-18 17:28:02 +08:00

606 lines
16 KiB
Go

package plugin
import (
"fmt"
"os"
"path/filepath"
"time"
_ "embed"
tea "github.com/charmbracelet/bubbletea"
"github.com/langgenius/dify-plugin-daemon/pkg/entities/constants"
"github.com/langgenius/dify-plugin-daemon/pkg/entities/manifest_entities"
"github.com/langgenius/dify-plugin-daemon/pkg/entities/plugin_entities"
"github.com/langgenius/dify-plugin-daemon/pkg/utils/log"
"github.com/langgenius/dify-plugin-daemon/pkg/utils/parser"
)
var (
//go:embed templates/icons/agent_light.svg
agentLight []byte
//go:embed templates/icons/agent_dark.svg
agentDark []byte
//go:embed templates/icons/datasource_light.svg
datasourceLight []byte
//go:embed templates/icons/datasource_dark.svg
datasourceDark []byte
//go:embed templates/icons/extension_light.svg
extensionLight []byte
//go:embed templates/icons/extension_dark.svg
extensionDark []byte
//go:embed templates/icons/model_light.svg
modelLight []byte
//go:embed templates/icons/model_dark.svg
modelDark []byte
//go:embed templates/icons/tool_light.svg
toolLight []byte
//go:embed templates/icons/tool_dark.svg
toolDark []byte
//go:embed templates/icons/trigger_light.svg
triggerLight []byte
//go:embed templates/icons/trigger_dark.svg
triggerDark []byte
//go:embed templates/readme/zh_Hans.md
zhHansReadme []byte
//go:embed templates/readme/ja_JP.md
jaJpReadme []byte
//go:embed templates/readme/pt_BR.md
ptBrReadme []byte
)
var icon = map[string]map[string][]byte{
"light": {
"agent-strategy": agentLight,
"datasource": datasourceLight,
"extension": extensionLight,
"model": modelLight,
"tool": toolLight,
"trigger": triggerLight,
},
"dark": {
"agent-strategy": agentDark,
"datasource": datasourceDark,
"extension": extensionDark,
"model": modelDark,
"tool": toolDark,
"trigger": triggerDark,
},
}
func InitPlugin() {
m := initialize()
p := tea.NewProgram(m)
if result, err := p.Run(); err != nil {
fmt.Println("Error running program:", err)
} else {
if m, ok := result.(model); ok {
if m.completed {
m.createPlugin()
}
} else {
log.Error("Error running program:", err)
return
}
}
}
func InitPluginWithFlags(
author string,
name string,
repo string,
description string,
allowRegisterEndpoint bool,
allowInvokeTool bool,
allowInvokeModel bool,
allowInvokeLLM bool,
allowInvokeTextEmbedding bool,
allowInvokeRerank bool,
allowInvokeTTS bool,
allowInvokeSpeech2Text bool,
allowInvokeModeration bool,
allowInvokeNode bool,
allowInvokeApp bool,
allowUseStorage bool,
storageSize uint64,
categoryStr string,
languageStr string,
minDifyVersion string,
quick bool,
) {
if quick {
// Validate name and author
if !plugin_entities.PluginNameRegex.MatchString(name) {
log.Error("Plugin name must be 1-128 characters long, and can only contain lowercase letters, numbers, dashes and underscores")
return
}
if !plugin_entities.AuthorRegex.MatchString(author) {
log.Error("Author name must be 1-64 characters long, and can only contain lowercase letters, numbers, dashes and underscores")
return
}
if description == "" {
log.Error("Description cannot be empty")
return
}
}
// Validate language
if languageStr != "" {
validLanguages := []string{
string(constants.Python),
// Add more languages here if supported
}
valid := false
for _, lang := range validLanguages {
if languageStr == lang {
valid = true
break
}
}
if !valid {
log.Error("Invalid language. Supported languages are: %v", validLanguages)
return
}
}
// Validate category
if categoryStr != "" {
validCategories := []string{
"tool",
"llm",
"text-embedding",
"speech2text",
"moderation",
"rerank",
"tts",
"extension",
"agent-strategy",
"datasource",
"trigger",
}
valid := false
for _, cat := range validCategories {
if categoryStr == cat {
valid = true
break
}
}
if !valid {
log.Error("Invalid category. Supported categories are: %v", validCategories)
return
}
}
m := newModel()
// Set profile information
profile := m.subMenus[SUB_MENU_KEY_PROFILE].(profile)
profile.SetAuthor(author)
profile.SetName(name)
profile.inputs[2].SetValue(description)
m.subMenus[SUB_MENU_KEY_PROFILE] = profile
// Set category if provided
if categoryStr != "" {
cat := m.subMenus[SUB_MENU_KEY_CATEGORY].(category)
cat.SetCategory(categoryStr)
m.subMenus[SUB_MENU_KEY_CATEGORY] = cat
}
// Set language if provided
if languageStr != "" {
lang := m.subMenus[SUB_MENU_KEY_LANGUAGE].(language)
lang.SetLanguage(languageStr)
m.subMenus[SUB_MENU_KEY_LANGUAGE] = lang
}
// Set minimal Dify version if provided
if minDifyVersion != "" {
ver := m.subMenus[SUB_MENU_KEY_VERSION_REQUIRE].(versionRequire)
ver.SetMinimalDifyVersion(minDifyVersion)
m.subMenus[SUB_MENU_KEY_VERSION_REQUIRE] = ver
}
// Update permissions
perm := m.subMenus[SUB_MENU_KEY_PERMISSION].(permission)
permissionRequirement := &plugin_entities.PluginPermissionRequirement{}
if allowRegisterEndpoint {
permissionRequirement.Endpoint = &plugin_entities.PluginPermissionEndpointRequirement{
Enabled: allowRegisterEndpoint,
}
}
if allowInvokeTool {
permissionRequirement.Tool = &plugin_entities.PluginPermissionToolRequirement{
Enabled: allowInvokeTool,
}
}
if allowInvokeModel {
permissionRequirement.Model = &plugin_entities.PluginPermissionModelRequirement{
Enabled: allowInvokeModel,
LLM: allowInvokeLLM,
TextEmbedding: allowInvokeTextEmbedding,
Rerank: allowInvokeRerank,
TTS: allowInvokeTTS,
Speech2text: allowInvokeSpeech2Text,
Moderation: allowInvokeModeration,
}
}
if allowInvokeNode {
permissionRequirement.Node = &plugin_entities.PluginPermissionNodeRequirement{
Enabled: allowInvokeNode,
}
}
if allowInvokeApp {
permissionRequirement.App = &plugin_entities.PluginPermissionAppRequirement{
Enabled: allowInvokeApp,
}
}
if allowUseStorage {
permissionRequirement.Storage = &plugin_entities.PluginPermissionStorageRequirement{
Enabled: allowUseStorage,
Size: storageSize,
}
}
perm.UpdatePermission(*permissionRequirement)
m.subMenus[SUB_MENU_KEY_PERMISSION] = perm
// If quick mode is enabled, skip interactive mode
if quick {
m.createPlugin()
return
}
// Otherwise, start interactive mode
p := tea.NewProgram(m)
if result, err := p.Run(); err != nil {
fmt.Println("Error running program:", err)
} else {
if m, ok := result.(model); ok {
if m.completed {
m.createPlugin()
}
} else {
log.Error("Error running program:", err)
return
}
}
}
type subMenuKey string
const (
SUB_MENU_KEY_PROFILE subMenuKey = "profile"
SUB_MENU_KEY_LANGUAGE subMenuKey = "language"
SUB_MENU_KEY_CATEGORY subMenuKey = "category"
SUB_MENU_KEY_PERMISSION subMenuKey = "permission"
SUB_MENU_KEY_VERSION_REQUIRE subMenuKey = "version_require"
)
type model struct {
subMenus map[subMenuKey]subMenu
subMenuSeq []subMenuKey
currentSubMenu subMenuKey
completed bool
}
func initialize() model {
m := model{}
m.subMenus = map[subMenuKey]subMenu{
SUB_MENU_KEY_PROFILE: newProfile(),
SUB_MENU_KEY_LANGUAGE: newLanguage(),
SUB_MENU_KEY_CATEGORY: newCategory(),
SUB_MENU_KEY_PERMISSION: newPermission(plugin_entities.PluginPermissionRequirement{}),
SUB_MENU_KEY_VERSION_REQUIRE: newVersionRequire(),
}
m.currentSubMenu = SUB_MENU_KEY_PROFILE
m.subMenuSeq = []subMenuKey{
SUB_MENU_KEY_PROFILE,
SUB_MENU_KEY_LANGUAGE,
SUB_MENU_KEY_CATEGORY,
SUB_MENU_KEY_PERMISSION,
SUB_MENU_KEY_VERSION_REQUIRE,
}
return m
}
func newModel() model {
m := model{}
m.subMenus = map[subMenuKey]subMenu{
SUB_MENU_KEY_PROFILE: newProfile(),
SUB_MENU_KEY_LANGUAGE: newLanguage(),
SUB_MENU_KEY_CATEGORY: newCategory(),
SUB_MENU_KEY_PERMISSION: newPermission(plugin_entities.PluginPermissionRequirement{}),
SUB_MENU_KEY_VERSION_REQUIRE: newVersionRequire(),
}
m.currentSubMenu = SUB_MENU_KEY_PROFILE
m.subMenuSeq = []subMenuKey{
SUB_MENU_KEY_PROFILE,
SUB_MENU_KEY_LANGUAGE,
SUB_MENU_KEY_CATEGORY,
SUB_MENU_KEY_PERMISSION,
SUB_MENU_KEY_VERSION_REQUIRE,
}
return m
}
func (m model) Init() tea.Cmd {
return nil
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
currentSubMenu, event, cmd := m.subMenus[m.currentSubMenu].Update(msg)
m.subMenus[m.currentSubMenu] = currentSubMenu
switch event {
case SUB_MENU_EVENT_NEXT:
if m.currentSubMenu != m.subMenuSeq[len(m.subMenuSeq)-1] {
// move the current sub menu to the next one
for i, key := range m.subMenuSeq {
if key == m.currentSubMenu {
// check if the next sub menu is permission
if key == SUB_MENU_KEY_CATEGORY {
// get the type of current category
category := m.subMenus[SUB_MENU_KEY_CATEGORY].(category).Category()
if category == "agent-strategy" {
// update the permission to add tool and model invocation
perm := m.subMenus[SUB_MENU_KEY_PERMISSION].(permission)
perm.UpdatePermission(plugin_entities.PluginPermissionRequirement{
Tool: &plugin_entities.PluginPermissionToolRequirement{
Enabled: true,
},
Model: &plugin_entities.PluginPermissionModelRequirement{
Enabled: true,
LLM: true,
},
})
m.subMenus[SUB_MENU_KEY_PERMISSION] = perm
}
}
m.currentSubMenu = m.subMenuSeq[i+1]
break
}
}
} else {
m.completed = true
return m, tea.Quit
}
case SUB_MENU_EVENT_PREV:
if m.currentSubMenu != m.subMenuSeq[0] {
// move the current sub menu to the previous one
for i, key := range m.subMenuSeq {
if key == m.currentSubMenu {
m.currentSubMenu = m.subMenuSeq[i-1]
break
}
}
}
}
return m, cmd
}
func (m model) View() string {
return m.subMenus[m.currentSubMenu].View()
}
func (m model) createPlugin() {
permission := m.subMenus[SUB_MENU_KEY_PERMISSION].(permission).Permission()
manifest := &plugin_entities.PluginDeclaration{
PluginDeclarationWithoutAdvancedFields: plugin_entities.PluginDeclarationWithoutAdvancedFields{
Version: manifest_entities.Version("0.0.1"),
Type: manifest_entities.PluginType,
Icon: "icon.svg",
IconDark: "icon-dark.svg",
Author: m.subMenus[SUB_MENU_KEY_PROFILE].(profile).Author(),
Name: m.subMenus[SUB_MENU_KEY_PROFILE].(profile).Name(),
Description: plugin_entities.NewI18nObject(m.subMenus[SUB_MENU_KEY_PROFILE].(profile).Description()),
CreatedAt: time.Now(),
Resource: plugin_entities.PluginResourceRequirement{
Memory: 1024 * 1024 * 256, // 256MB
Permission: &permission,
},
Label: plugin_entities.NewI18nObject(m.subMenus[SUB_MENU_KEY_PROFILE].(profile).Name()),
Privacy: parser.ToPtr("PRIVACY.md"),
},
}
repo := m.subMenus[SUB_MENU_KEY_PROFILE].(profile).Repo()
if repo != "" {
manifest.Repo = parser.ToPtr(repo)
}
categoryString := m.subMenus[SUB_MENU_KEY_CATEGORY].(category).Category()
if categoryString == "tool" {
manifest.Plugins.Tools = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
}
if categoryString == "llm" ||
categoryString == "text-embedding" ||
categoryString == "speech2text" ||
categoryString == "moderation" ||
categoryString == "rerank" ||
categoryString == "tts" {
manifest.Plugins.Models = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
}
if categoryString == "extension" {
manifest.Plugins.Endpoints = []string{fmt.Sprintf("group/%s.yaml", manifest.Name)}
}
if categoryString == "agent-strategy" {
manifest.Plugins.AgentStrategies = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
}
if categoryString == "datasource" {
manifest.Plugins.Datasources = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
}
if categoryString == "trigger" {
manifest.Plugins.Triggers = []string{fmt.Sprintf("provider/%s.yaml", manifest.Name)}
}
manifest.Meta = plugin_entities.PluginMeta{
Version: "0.0.1",
Arch: []constants.Arch{
constants.AMD64,
constants.ARM64,
},
Runner: plugin_entities.PluginRunner{},
}
if m.subMenus[SUB_MENU_KEY_VERSION_REQUIRE].(versionRequire).MinimalDifyVersion() != "" {
manifest.Meta.MinimumDifyVersion = parser.ToPtr(m.subMenus[SUB_MENU_KEY_VERSION_REQUIRE].(versionRequire).MinimalDifyVersion())
}
switch m.subMenus[SUB_MENU_KEY_LANGUAGE].(language).Language() {
case constants.Python:
manifest.Meta.Runner.Entrypoint = "main"
manifest.Meta.Runner.Language = constants.Python
manifest.Meta.Runner.Version = "3.12"
default:
log.Error("unsupported language: %s", m.subMenus[SUB_MENU_KEY_LANGUAGE].(language).Language())
return
}
success := false
clear := func() {
if !success {
os.RemoveAll(manifest.Name)
}
}
defer clear()
manifestFile := marshalYamlBytes(manifest)
// create the plugin directory
cwd, err := os.Getwd()
if err != nil {
log.Error("failed to get current working directory: %s", err)
return
}
pluginDir := filepath.Join(cwd, manifest.Name)
if err := writeFile(filepath.Join(pluginDir, "manifest.yaml"), string(manifestFile)); err != nil {
log.Error("failed to write manifest file: %s", err)
return
}
// get icon and icon-dark
iconLight := icon["light"][string(manifest.Category())]
if iconLight == nil {
log.Error("icon not found for category: %s", manifest.Category())
return
}
iconDark := icon["dark"][string(manifest.Category())]
if iconDark == nil {
log.Error("icon-dark not found for category: %s", manifest.Category())
return
}
// create icon.svg
if err := writeFile(filepath.Join(pluginDir, "_assets", "icon.svg"), string(iconLight)); err != nil {
log.Error("failed to write icon file: %s", err)
return
}
// create icon-dark.svg
if err := writeFile(filepath.Join(pluginDir, "_assets", "icon-dark.svg"), string(iconDark)); err != nil {
log.Error("failed to write icon-dark file: %s", err)
return
}
// create README.md
readme, err := renderTemplate(README, manifest, []string{})
if err != nil {
log.Error("failed to render README template: %s", err)
return
}
if err := writeFile(filepath.Join(pluginDir, "README.md"), readme); err != nil {
log.Error("failed to write README file: %s", err)
return
}
// create multilingual README files if enabled
profileMenu := m.subMenus[SUB_MENU_KEY_PROFILE].(profile)
if profileMenu.EnableI18nReadme() {
selectedLanguages := profileMenu.SelectedLanguages()
// Define language template mapping
languageTemplates := map[string][]byte{
"zh_Hans": zhHansReadme,
"ja_JP": jaJpReadme,
"pt_BR": ptBrReadme,
}
for _, lang := range selectedLanguages {
if lang == "en" {
// English README is already created as README.md
continue
}
if template, exists := languageTemplates[lang]; exists {
// Render the template for this language
langReadme, err := renderTemplate(template, manifest, []string{})
if err != nil {
log.Error("failed to render %s README template: %s", lang, err)
return
}
// Write the language-specific README file
readmeFilename := fmt.Sprintf("README_%s.md", lang)
if err := writeFile(filepath.Join(pluginDir, "readme", readmeFilename), langReadme); err != nil {
log.Error("failed to write %s README file: %s", lang, err)
return
}
}
}
}
// create .env.example
if err := writeFile(filepath.Join(pluginDir, ".env.example"), string(ENV_EXAMPLE)); err != nil {
log.Error("failed to write .env.example file: %s", err)
return
}
// create PRIVACY.md
if err := writeFile(filepath.Join(pluginDir, "PRIVACY.md"), string(PRIVACY)); err != nil {
log.Error("failed to write PRIVACY file: %s", err)
return
}
// create github CI workflow
if err := writeFile(filepath.Join(pluginDir, ".github", "workflows", "plugin-publish.yml"), string(PLUGIN_PUBLISH_WORKFLOW)); err != nil {
log.Error("failed to write plugin-publish workflow file: %s", err)
return
}
err = createPythonEnvironment(
pluginDir,
manifest.Meta.Runner.Entrypoint,
manifest,
m.subMenus[SUB_MENU_KEY_CATEGORY].(category).Category(),
)
if err != nil {
log.Error("failed to create python environment: %s", err)
return
}
success = true
log.Info("plugin %s created successfully, you can refer to `%s/GUIDE.md` for more information about how to develop it", manifest.Name, manifest.Name)
}