mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 16:46:57 +08:00
* feat(i18n): translate web console messages in the frontend and AI-client errors in the backend
Chinese text was hard-coded in API messages, stored log content, emails and
server logs, so it reached users of every interface language.
Text is now translated where its reader is:
- Web console APIs return the English source text as message_key with
message_params, and the web console translates it in all seven languages.
message keeps the English text for other API callers.
- Errors returned to AI clients, emails and notifications are translated by
the backend (en, zh-CN, zh-TW) in the language saved on the account, then
the language of the request's Accept-Language header.
- A reader who states no language still gets the original Chinese text of the
messages that were Chinese before, word for word. Gateways that use another
new-api as their upstream match that text, for example to disable a channel
whose account ran out of quota.
- Usage, top-up and system log content is stored as content_parts (key and
params) and rendered in the viewer's language. Task fail reasons and refund
reasons written by the gateway are English keys. Rows written earlier are
shown as before.
- Server logs are English.
Also:
- The saved interface language zhTW, and zh-HK, zh-MO and zh-Hant, now select
Traditional Chinese in the backend instead of Simplified Chinese.
- The built-in group descriptions are translated; descriptions an
administrator saved are shown as written.
- .agents/rules/i18n.md records the conventions. Tests in i18n/ fail on a
non-English string literal, on a web console message without translations
and on a locale key without a constant.
* feat(i18n): print server logs in Chinese when LOG_LANGUAGE asks for it
Server log lines are English by default. With LOG_LANGUAGE=zh-CN, the lines
that were Chinese before print their original Chinese text.
common.LogText formats a log line from its English format, which is also the
key of its Chinese text in common/log_text.zh-CN.json. A line without an entry
prints in English. go vet checks the arguments of every call, and
TestServerLogTexts checks that each entry still matches a call and has the
same verbs as its key.
* docs: list LOG_LANGUAGE in the docker compose example
* feat(i18n): translate every server log line and add DEFAULT_LANGUAGE
Every line the gateway prints goes through common.LogText. With
DEFAULT_LANGUAGE=zh-CN, the lines that have a Chinese text in
common/log_text.zh-CN.json print it and the others print English.
DEFAULT_LANGUAGE replaces LOG_LANGUAGE. It also sets the language of
backend messages, emails and notifications for a reader who states no
language: en, zh-CN or zh-TW, English when unset. The list of messages
that stayed Chinese for such readers is removed.
Review fixes:
- Channel test logs store the token name "Model test"; the console shows
it in the interface language.
- Task fail reasons written by the gateway are fixed sentences the console
translates. The HTTP status and the upstream detail go to the server log
when a polled task is failed.
- The auto-disable reason of a slow channel is written in DEFAULT_LANGUAGE.
Also:
- Drop the repository-scanning lint tests, the i18n rules file and its
read gate. AGENTS.md keeps the conventions in four lines.
- Payment error toasts keep their previous text and only add the
translated message key.
- Remove guards and helpers that no caller needs.
* fix(i18n): address review on language lookup, notices and error responses
- Relay requests no longer load the user a second time to find the
language: the settings TokenAuth put into the request are used as they
are, also when they hold no language.
- Upstream model update notices are rendered in each recipient's saved
language, DEFAULT_LANGUAGE when none.
- /api/usage/token and /api/log/token answer with backend-translated
messages again, with the keys and status codes used before this PR.
Their callers are scripts, not the web console.
- Webhook notifications fill {{value}} placeholders like the other
senders. Running the rendered text through Sprintf appended
%!(EXTRA ...) and broke %2F in links.
- RootUserLanguage reads the setting of the root user it already loaded.
- zh_TW and zh_HK are read as Traditional Chinese.
- Add common.ApiErrorStatus for error responses under another HTTP status
and use it where those responses were built by hand.
- Remove middleware.GetLanguage, which had no callers.
873 lines
28 KiB
Go
873 lines
28 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/constant"
|
|
"github.com/QuantumNous/new-api/logger"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
|
"github.com/QuantumNous/new-api/plugins"
|
|
"github.com/QuantumNous/new-api/setting"
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
const maxTaskPluginSourceBytes = 8 << 20
|
|
|
|
func taskPluginCompileError(c *gin.Context, err error) {
|
|
var unknownField *jsplugin.UnknownMetaFieldError
|
|
if errors.As(err, &unknownField) {
|
|
common.ApiErrorT(c, "Plugin metadata contains an unknown field \"{{field}}\". If this plugin was downloaded from the official marketplace, it may require a newer version of new-api. Try updating new-api and installing the plugin again.", map[string]any{"field": unknownField.Field})
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
}
|
|
|
|
type taskPluginUploadRequest struct {
|
|
Source string `json:"source" binding:"required"`
|
|
Enabled *bool `json:"enabled"`
|
|
Remark string `json:"remark"`
|
|
Force bool `json:"force"`
|
|
SourceSha256 string `json:"sourceSha256"`
|
|
// Icon carries the sidecar icon.svg / icon.png as a data URI. It is optional
|
|
// and stored separately from the source so the JavaScript stays readable.
|
|
Icon string `json:"icon"`
|
|
}
|
|
|
|
func UploadTaskPlugin(c *gin.Context) {
|
|
var request taskPluginUploadRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
if len(request.Source) > maxTaskPluginSourceBytes {
|
|
common.ApiErrorMsg(c, "plugin source exceeds 8 MiB")
|
|
return
|
|
}
|
|
if expected := strings.TrimSpace(request.SourceSha256); expected != "" {
|
|
actual := fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source)))
|
|
if !strings.EqualFold(actual, expected) {
|
|
common.ApiErrorMsg(c, "plugin source sha256 mismatch")
|
|
return
|
|
}
|
|
}
|
|
temporary := jsplugin.NewRegistry()
|
|
loaded, err := temporary.Register(request.Source, jsplugin.Options{})
|
|
if err != nil {
|
|
taskPluginCompileError(c, err)
|
|
return
|
|
}
|
|
if err = jsplugin.ValidateV1Meta(loaded.Meta); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
icon := strings.TrimSpace(request.Icon)
|
|
if icon != "" {
|
|
if _, _, err = jsplugin.DecodeIconDataURI(icon); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
enabled := true
|
|
if request.Enabled != nil {
|
|
enabled = *request.Enabled
|
|
}
|
|
if enabled && !request.Force {
|
|
if err = jsplugin.PreflightRoutingConflict(jsplugin.DefaultRegistry.Generation(), loaded); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
}
|
|
plugin := model.TaskPlugin{
|
|
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
|
|
Source: model.LongText(request.Source), SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
|
|
Icon: model.LongText(icon), Enabled: enabled, Remark: request.Remark,
|
|
}
|
|
if err = model.SaveTaskPlugin(&plugin); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
|
|
}
|
|
|
|
func GetTaskPluginVersions(c *gin.Context) {
|
|
plugins, err := model.ListTaskPluginVersions(c.Param("key"))
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, plugins)
|
|
}
|
|
|
|
type taskPluginListItem struct {
|
|
Meta jsplugin.Meta `json:"meta"`
|
|
Source string `json:"source"`
|
|
Enabled bool `json:"enabled"`
|
|
Active bool `json:"active"`
|
|
SourceHash string `json:"source_hash"`
|
|
HasIcon bool `json:"has_icon"`
|
|
Remark string `json:"remark"`
|
|
RuntimeStatus string `json:"runtime_status"`
|
|
RuntimeError string `json:"runtime_error,omitempty"`
|
|
FactoryMeta *jsplugin.Meta `json:"factory_meta,omitempty"`
|
|
ChannelCount int `json:"channel_count"`
|
|
InFlightCount int64 `json:"in_flight_count"`
|
|
}
|
|
|
|
type taskPluginRebuildOutcome struct {
|
|
Status string `json:"status"`
|
|
AttemptedAt time.Time `json:"attempted_at"`
|
|
Generation uint64 `json:"generation"`
|
|
DatabaseRevision string `json:"database_revision,omitempty"`
|
|
PluginErrorCount int `json:"plugin_error_count"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type taskPluginRuntimeStatus struct {
|
|
CurrentGeneration uint64 `json:"current_generation"`
|
|
GenerationPublishedAt time.Time `json:"generation_published_at"`
|
|
DatabaseRevision string `json:"database_revision"`
|
|
DatabaseError string `json:"database_error,omitempty"`
|
|
LastRebuild taskPluginRebuildOutcome `json:"last_rebuild"`
|
|
PluginErrors map[string]string `json:"plugin_errors"`
|
|
}
|
|
|
|
func ListTaskPlugins(c *gin.Context) {
|
|
databasePlugins, err := model.ListTaskPlugins()
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
snapshot := jsplugin.DefaultRegistry.Snapshot()
|
|
factory := make(map[string]jsplugin.Meta, len(snapshot.Factory))
|
|
override := make(map[string]jsplugin.Meta, len(snapshot.Override))
|
|
for _, meta := range snapshot.Factory {
|
|
factory[meta.Key] = meta
|
|
}
|
|
for _, meta := range snapshot.Override {
|
|
override[meta.Key] = meta
|
|
}
|
|
activeRows := make(map[string]model.TaskPlugin)
|
|
keys := make(map[string]struct{}, len(factory)+len(databasePlugins))
|
|
for key := range factory {
|
|
keys[key] = struct{}{}
|
|
}
|
|
for _, plugin := range databasePlugins {
|
|
keys[plugin.Key] = struct{}{}
|
|
if plugin.Active {
|
|
activeRows[plugin.Key] = plugin
|
|
}
|
|
}
|
|
|
|
runtimeErrors := jsplugin.DefaultRegistry.RoutingErrors()
|
|
taskPluginSyncState.Lock()
|
|
maps.Copy(runtimeErrors, taskPluginSyncState.errors)
|
|
taskPluginSyncState.Unlock()
|
|
|
|
items := make([]taskPluginListItem, 0, len(keys))
|
|
for key := range keys {
|
|
factoryMeta, hasFactory := factory[key]
|
|
row, hasOverride := activeRows[key]
|
|
item := taskPluginListItem{Enabled: true, Active: true, RuntimeStatus: "registered"}
|
|
if hasOverride {
|
|
item.Source = "override"
|
|
if hasFactory {
|
|
item.Source = "override_over_factory"
|
|
factoryCopy := factoryMeta
|
|
item.FactoryMeta = &factoryCopy
|
|
}
|
|
item.Meta = jsplugin.Meta{Key: row.Key, Version: row.Version, APIVersion: row.APIVersion}
|
|
if compiled, compileErr := jsplugin.NewRegistry().Register(string(row.Source), jsplugin.Options{Key: row.Key, Version: row.Version}); compileErr == nil {
|
|
item.Meta = compiled.Meta
|
|
}
|
|
item.Enabled = row.Enabled
|
|
item.Active = row.Active
|
|
item.SourceHash = row.SourceHash
|
|
item.HasIcon = row.HasIcon()
|
|
item.Remark = row.Remark
|
|
if message := runtimeErrors[key]; message != "" {
|
|
item.RuntimeStatus = "compile_failed"
|
|
item.RuntimeError = message
|
|
} else if runtimeMeta, ok := override[key]; ok {
|
|
item.Meta = runtimeMeta
|
|
} else if !row.Enabled {
|
|
item.RuntimeStatus = "disabled_fallback"
|
|
} else {
|
|
item.RuntimeStatus = "not_registered"
|
|
}
|
|
// "disabled_fallback" promises that the built-in still serves. When
|
|
// the factory layer is suppressed as well, nothing serves this key.
|
|
if item.RuntimeStatus == "disabled_fallback" && hasFactory && setting.IsTaskPluginFactoryDisabled(key) {
|
|
item.RuntimeStatus = "disabled"
|
|
}
|
|
} else {
|
|
item.Source = "factory"
|
|
item.Meta = factoryMeta
|
|
item.Enabled = !setting.IsTaskPluginFactoryDisabled(key)
|
|
_, _, item.HasIcon = plugins.Icon(key)
|
|
source, sourceErr := plugins.Source(key)
|
|
if sourceErr == nil {
|
|
item.SourceHash = fmt.Sprintf("%x", sha256.Sum256([]byte(source)))
|
|
}
|
|
if !item.Enabled {
|
|
item.RuntimeStatus = "disabled"
|
|
} else if message := runtimeErrors[key]; message != "" {
|
|
item.RuntimeStatus = "compile_failed"
|
|
item.RuntimeError = message
|
|
}
|
|
}
|
|
if !hasFactory {
|
|
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
|
|
if usageErr != nil {
|
|
common.ApiError(c, usageErr)
|
|
return
|
|
}
|
|
item.ChannelCount = len(channels)
|
|
item.InFlightCount = inFlight
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].Meta.SortPriority != items[j].Meta.SortPriority {
|
|
return items[i].Meta.SortPriority > items[j].Meta.SortPriority
|
|
}
|
|
return items[i].Meta.Key < items[j].Meta.Key
|
|
})
|
|
common.ApiSuccess(c, items)
|
|
}
|
|
|
|
func GetTaskPluginRuntime(c *gin.Context) {
|
|
routingStatus := jsplugin.DefaultRegistry.RoutingStatus()
|
|
pluginErrors := routingStatus.Errors
|
|
|
|
taskPluginSyncState.Lock()
|
|
maps.Copy(pluginErrors, taskPluginSyncState.errors)
|
|
lastRebuild := taskPluginSyncState.lastRebuild
|
|
lastDatabaseRevision := lastRebuild.DatabaseRevision
|
|
taskPluginSyncState.Unlock()
|
|
|
|
registryRebuild := routingStatus.LastRebuild
|
|
if lastRebuild.AttemptedAt.Before(registryRebuild.AttemptedAt) {
|
|
lastRebuild = taskPluginRebuildOutcome{
|
|
Status: registryRebuild.Status,
|
|
AttemptedAt: registryRebuild.AttemptedAt,
|
|
Generation: registryRebuild.Generation,
|
|
Error: registryRebuild.Error,
|
|
}
|
|
}
|
|
if lastRebuild.Status == "" {
|
|
lastRebuild.Status = "never"
|
|
}
|
|
lastRebuild.PluginErrorCount = len(pluginErrors)
|
|
if lastRebuild.Status == "success" && len(pluginErrors) > 0 {
|
|
lastRebuild.Status = "partial"
|
|
}
|
|
|
|
status := taskPluginRuntimeStatus{
|
|
DatabaseRevision: lastDatabaseRevision,
|
|
LastRebuild: lastRebuild,
|
|
PluginErrors: pluginErrors,
|
|
}
|
|
databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
|
|
if err != nil {
|
|
status.DatabaseError = "database snapshot unavailable"
|
|
} else {
|
|
status.DatabaseRevision = databaseSnapshot.Revision
|
|
}
|
|
if routingStatus.Generation != nil {
|
|
status.CurrentGeneration = routingStatus.Generation.Number
|
|
status.GenerationPublishedAt = routingStatus.Generation.PublishedAt
|
|
}
|
|
common.ApiSuccess(c, status)
|
|
}
|
|
|
|
type taskPluginDetail struct {
|
|
Plugin *model.TaskPlugin `json:"plugin,omitempty"`
|
|
Meta jsplugin.Meta `json:"meta"`
|
|
Source string `json:"source"`
|
|
Layer string `json:"layer"`
|
|
HasIcon bool `json:"has_icon"`
|
|
}
|
|
|
|
// GetTaskPluginIcon serves a plugin logo as an image. The active override wins
|
|
// (or the requested ?version=), then the factory sidecar. Data icons are only
|
|
// ever drawn through <img>, and the nosniff header keeps a browser from
|
|
// treating an SVG response as a document.
|
|
func GetTaskPluginIcon(c *gin.Context) {
|
|
key := c.Param("key")
|
|
icon := ""
|
|
plugin, err := model.GetTaskPluginVersion(key, c.Query("version"))
|
|
if err == nil {
|
|
icon = string(plugin.Icon)
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if icon == "" && c.Query("version") == "" {
|
|
icon = plugins.IconDataURI(key)
|
|
}
|
|
if icon == "" {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
mediaType, data, err := jsplugin.DecodeIconDataURI(icon)
|
|
if err != nil {
|
|
c.AbortWithStatus(http.StatusNotFound)
|
|
return
|
|
}
|
|
c.Header("Cache-Control", "private, max-age=3600")
|
|
c.Header("X-Content-Type-Options", "nosniff")
|
|
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
|
c.Data(http.StatusOK, mediaType, data)
|
|
}
|
|
|
|
func GetTaskPlugin(c *gin.Context) {
|
|
key := c.Param("key")
|
|
version := c.Query("version")
|
|
plugin, err := model.GetTaskPluginVersion(key, version)
|
|
if err == nil {
|
|
loaded, compileErr := jsplugin.NewRegistry().Register(string(plugin.Source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
|
if compileErr != nil {
|
|
taskPluginCompileError(c, compileErr)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: string(plugin.Source), Layer: "override", HasIcon: plugin.HasIcon()})
|
|
return
|
|
}
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
source, err := plugins.Source(key)
|
|
if err != nil {
|
|
common.ApiErrorMsg(c, "task plugin not found")
|
|
return
|
|
}
|
|
loaded, err := jsplugin.NewRegistry().RegisterFactory(source, jsplugin.Options{Key: key})
|
|
if err != nil {
|
|
taskPluginCompileError(c, err)
|
|
return
|
|
}
|
|
_, _, hasIcon := plugins.Icon(key)
|
|
common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory", HasIcon: hasIcon})
|
|
}
|
|
|
|
type taskPluginDryRunRequest struct {
|
|
Hook string `json:"hook" binding:"required"`
|
|
Member string `json:"member"`
|
|
Args []json.RawMessage `json:"args"`
|
|
}
|
|
|
|
func DryRunTaskPlugin(c *gin.Context) {
|
|
var request taskPluginDryRunRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
detailSource := ""
|
|
plugin, err := model.GetTaskPluginVersion(c.Param("key"), "")
|
|
if err == nil {
|
|
detailSource = string(plugin.Source)
|
|
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
detailSource, err = plugins.Source(c.Param("key"))
|
|
}
|
|
if err != nil {
|
|
common.ApiErrorMsg(c, "task plugin not found")
|
|
return
|
|
}
|
|
loaded, err := jsplugin.NewRegistry().Register(detailSource, jsplugin.Options{Key: c.Param("key")})
|
|
if err != nil {
|
|
taskPluginCompileError(c, err)
|
|
return
|
|
}
|
|
args := make([]any, len(request.Args))
|
|
for index, raw := range request.Args {
|
|
if err = common.Unmarshal(raw, &args[index]); err != nil {
|
|
common.ApiErrorMsg(c, fmt.Sprintf("invalid argument %d: %v", index+1, err))
|
|
return
|
|
}
|
|
}
|
|
var output any
|
|
if request.Member == "" {
|
|
output, err = loaded.Engine.Call(context.Background(), request.Hook, args...)
|
|
} else {
|
|
output, err = loaded.Engine.CallMember(context.Background(), request.Hook, request.Member, args...)
|
|
}
|
|
if err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
common.ApiSuccess(c, output)
|
|
}
|
|
|
|
func DeleteTaskPluginVersion(c *gin.Context) {
|
|
key := c.Param("key")
|
|
version := c.Param("version")
|
|
plugin, lookupErr := model.GetTaskPluginVersion(key, version)
|
|
if lookupErr != nil {
|
|
if errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
|
|
return
|
|
}
|
|
common.ApiError(c, lookupErr)
|
|
return
|
|
}
|
|
if plugin.Active && !taskPluginHasFactory(key) {
|
|
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
|
|
if usageErr != nil {
|
|
common.ApiError(c, usageErr)
|
|
return
|
|
}
|
|
if (len(channels) > 0 || inFlight > 0) && c.Query("force") != "true" {
|
|
c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
|
|
return
|
|
}
|
|
}
|
|
_, err := model.DeleteTaskPluginVersion(key, version)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
common.ApiErrorMsg(c, "override plugin version not found; factory plugins cannot be deleted")
|
|
return
|
|
}
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, nil)
|
|
}
|
|
|
|
type taskPluginActivateRequest struct {
|
|
Version string `json:"version" binding:"required"`
|
|
}
|
|
|
|
func ActivateTaskPlugin(c *gin.Context) {
|
|
var request taskPluginActivateRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
versions, err := model.ListTaskPluginVersions(c.Param("key"))
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
var target *model.TaskPlugin
|
|
for i := range versions {
|
|
if versions[i].Version == request.Version {
|
|
target = &versions[i]
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
common.ApiErrorMsg(c, "plugin version not found")
|
|
return
|
|
}
|
|
if _, err = jsplugin.NewRegistry().Register(string(target.Source), jsplugin.Options{Key: target.Key, Version: target.Version}); err != nil {
|
|
taskPluginCompileError(c, err)
|
|
return
|
|
}
|
|
if err = model.ActivateTaskPlugin(target.Key, target.Version); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err = syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, nil)
|
|
}
|
|
|
|
type taskPluginStatusRequest struct {
|
|
Enabled *bool `json:"enabled" binding:"required"`
|
|
}
|
|
|
|
func SetTaskPluginStatus(c *gin.Context) {
|
|
var request taskPluginStatusRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil || request.Enabled == nil {
|
|
common.ApiErrorMsg(c, "enabled is required")
|
|
return
|
|
}
|
|
key := c.Param("key")
|
|
disabledChannels := 0
|
|
unboundChannels := 0
|
|
if !*request.Enabled {
|
|
channels, inFlight, usageErr := model.GetTaskPluginUsage(key)
|
|
if usageErr != nil {
|
|
common.ApiError(c, usageErr)
|
|
return
|
|
}
|
|
cascade := c.Query("cascade") == "true"
|
|
force := c.Query("force") == "true"
|
|
if (len(channels) > 0 && !cascade) || (inFlight > 0 && !force) {
|
|
c.JSON(200, gin.H{"success": false, "message": "task plugin is still in use", "data": gin.H{"channels": channels, "in_flight_count": inFlight}})
|
|
return
|
|
}
|
|
if cascade {
|
|
for _, channel := range channels {
|
|
if channel.Type == constant.ChannelTypeNewAPI {
|
|
// A gateway channel also carries ordinary traffic, so the
|
|
// cascade only drops this plugin from its bindings.
|
|
unbound, unbindErr := model.UnbindTaskPlugin(channel.Id, key)
|
|
if unbindErr != nil {
|
|
common.ApiError(c, unbindErr)
|
|
return
|
|
}
|
|
if unbound {
|
|
unboundChannels++
|
|
}
|
|
continue
|
|
}
|
|
if model.UpdateChannelStatus(channel.Id, "", common.ChannelStatusManuallyDisabled, "task plugin disabled") {
|
|
disabledChannels++
|
|
}
|
|
}
|
|
if unboundChannels > 0 {
|
|
model.InitChannelCache()
|
|
}
|
|
}
|
|
}
|
|
_, lookupErr := model.GetTaskPluginVersion(key, "")
|
|
hasActiveOverride := lookupErr == nil
|
|
if lookupErr != nil && !errors.Is(lookupErr, gorm.ErrRecordNotFound) {
|
|
common.ApiError(c, lookupErr)
|
|
return
|
|
}
|
|
// Switching a key off must silence every layer that can serve it. The
|
|
// factory built-in goes into the disabled set even when an override row
|
|
// exists; otherwise the built-in would keep routing the same models (and
|
|
// blocking same-model uploads) right after the administrator disabled the
|
|
// plugin. Switching on reverses both layers.
|
|
if taskPluginHasFactory(key) {
|
|
keys := setting.GetTaskPluginDisabledFactoryKeys()
|
|
if *request.Enabled {
|
|
next := make([]string, 0, len(keys))
|
|
for _, item := range keys {
|
|
if item != key {
|
|
next = append(next, item)
|
|
}
|
|
}
|
|
keys = next
|
|
} else {
|
|
keys = append(append([]string{}, keys...), key)
|
|
}
|
|
if err := setting.SetTaskPluginDisabledFactoryKeysOption(keys); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
encoded, err := common.Marshal(setting.GetTaskPluginDisabledFactoryKeys())
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err = model.UpdateOption(setting.TaskPluginDisabledFactoryKeysKey, string(encoded)); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if !hasActiveOverride {
|
|
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels, "unbound_channels": unboundChannels})
|
|
return
|
|
}
|
|
}
|
|
if err := model.SetTaskPluginEnabled(key, *request.Enabled); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err := syncTaskPluginsOnceContext(c.Request.Context()); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, gin.H{"plugin_enabled": *request.Enabled, "disabled_channels": disabledChannels, "unbound_channels": unboundChannels})
|
|
}
|
|
|
|
func taskPluginHasFactory(key string) bool {
|
|
for _, meta := range jsplugin.DefaultRegistry.Snapshot().Factory {
|
|
if meta.Key == key {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func GetTaskPluginMarketplaceSources(c *gin.Context) {
|
|
common.ApiSuccess(c, setting.GetTaskPluginMarketplaceSources())
|
|
}
|
|
|
|
func UpdateTaskPluginMarketplaceSources(c *gin.Context) {
|
|
var sources []setting.TaskPluginMarketplaceSource
|
|
if err := c.ShouldBindJSON(&sources); err != nil {
|
|
common.ApiErrorMsg(c, err.Error())
|
|
return
|
|
}
|
|
if sources == nil {
|
|
sources = []setting.TaskPluginMarketplaceSource{}
|
|
}
|
|
for i := range sources {
|
|
name := strings.TrimSpace(sources[i].Name)
|
|
indexURL := strings.TrimSpace(sources[i].IndexURL)
|
|
if name == "" {
|
|
common.ApiErrorMsg(c, "marketplace source name is required")
|
|
return
|
|
}
|
|
parsed, err := url.Parse(indexURL)
|
|
if err != nil || !parsed.IsAbs() || parsed.Host == "" || (!strings.EqualFold(parsed.Scheme, "http") && !strings.EqualFold(parsed.Scheme, "https")) {
|
|
common.ApiErrorMsg(c, "marketplace source index_url must be an absolute http(s) URL")
|
|
return
|
|
}
|
|
sources[i].Name = name
|
|
sources[i].IndexURL = indexURL
|
|
}
|
|
encoded, err := common.Marshal(sources)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
if err = model.UpdateOption(setting.TaskPluginMarketplaceSourcesKey, string(encoded)); err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, sources)
|
|
}
|
|
|
|
func GetTaskPluginOptions(c *gin.Context) {
|
|
snapshot := jsplugin.DefaultRegistry.Snapshot()
|
|
seen := make(map[string]bool)
|
|
options := make([]gin.H, 0, len(snapshot.Factory)+len(snapshot.Override))
|
|
for layer, metas := range [][]jsplugin.Meta{snapshot.Override, snapshot.Factory} {
|
|
for _, meta := range metas {
|
|
if seen[meta.Key] {
|
|
continue
|
|
}
|
|
// Disabled factory keys are omitted from bind options. The disabled
|
|
// set suppresses only the factory fallback; an enabled override for
|
|
// the same key is listed in the override pass and still appears.
|
|
if layer == 1 && setting.IsTaskPluginFactoryDisabled(meta.Key) {
|
|
continue
|
|
}
|
|
if _, ok := jsplugin.DefaultRegistry.Get(meta.Key); !ok {
|
|
continue
|
|
}
|
|
seen[meta.Key] = true
|
|
hasIcon := false
|
|
if layer == 0 {
|
|
if row, rowErr := model.GetTaskPluginVersion(meta.Key, ""); rowErr == nil {
|
|
hasIcon = row.HasIcon()
|
|
}
|
|
} else {
|
|
_, _, hasIcon = plugins.Icon(meta.Key)
|
|
}
|
|
options = append(options, gin.H{
|
|
"key": meta.Key,
|
|
"name": meta.Name,
|
|
"description": meta.Description,
|
|
"icon": meta.Icon,
|
|
"hasIcon": hasIcon,
|
|
"baseUrl": meta.BaseURL,
|
|
"sortPriority": meta.SortPriority,
|
|
"website": meta.Website,
|
|
"models": meta.Models,
|
|
"channelTypes": meta.ChannelTypes,
|
|
"upstreams": meta.Upstreams,
|
|
"usageSchema": meta.UsageSchema,
|
|
"usageProfiles": meta.UsageProfiles,
|
|
})
|
|
}
|
|
}
|
|
sort.Slice(options, func(i, j int) bool {
|
|
left, right := options[i]["sortPriority"].(int), options[j]["sortPriority"].(int)
|
|
if left != right {
|
|
return left > right
|
|
}
|
|
return options[i]["key"].(string) < options[j]["key"].(string)
|
|
})
|
|
common.ApiSuccess(c, options)
|
|
}
|
|
|
|
var taskPluginSyncState = struct {
|
|
sync.Mutex
|
|
hashes map[string]string
|
|
errors map[string]string
|
|
lastRebuild taskPluginRebuildOutcome
|
|
}{hashes: map[string]string{}, errors: map[string]string{}}
|
|
|
|
func syncTaskPluginsOnce() error {
|
|
return syncTaskPluginsOnceContext(context.Background())
|
|
}
|
|
|
|
func syncTaskPluginsOnceContext(ctx context.Context) error {
|
|
started := time.Now()
|
|
taskPluginSyncState.Lock()
|
|
defer taskPluginSyncState.Unlock()
|
|
databaseSnapshot, err := model.GetTaskPluginSyncSnapshot()
|
|
if err != nil {
|
|
syncErr := fmt.Errorf("sync task plugins: %w", err)
|
|
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
|
|
Status: "failed",
|
|
AttemptedAt: time.Now(),
|
|
Generation: jsplugin.DefaultRegistry.Generation().Number,
|
|
DatabaseRevision: taskPluginSyncState.lastRebuild.DatabaseRevision,
|
|
Error: syncErr.Error(),
|
|
}
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=failed stage=database_snapshot retained_generation=%d elapsed_ms=%d",
|
|
jsplugin.DefaultRegistry.Generation().Number,
|
|
time.Since(started).Milliseconds(),
|
|
)
|
|
return syncErr
|
|
}
|
|
databasePlugins := databaseSnapshot.Plugins
|
|
sort.Slice(databasePlugins, func(i, j int) bool { return databasePlugins[i].Key < databasePlugins[j].Key })
|
|
currentOverrides := jsplugin.DefaultRegistry.OverridePlugins()
|
|
generationBefore := jsplugin.DefaultRegistry.Generation().Number
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=start database_revision=%q generation=%d desired_plugins=%d current_overrides=%d",
|
|
databaseSnapshot.Revision,
|
|
generationBefore,
|
|
len(databasePlugins),
|
|
len(currentOverrides),
|
|
)
|
|
nextOverrides := make([]*jsplugin.LoadedPlugin, 0, len(databasePlugins))
|
|
nextHashes := make(map[string]string, len(databasePlugins))
|
|
seen := make(map[string]bool, len(databasePlugins))
|
|
for _, plugin := range databasePlugins {
|
|
seen[plugin.Key] = true
|
|
if current := currentOverrides[plugin.Key]; current != nil && taskPluginSyncState.hashes[plugin.Key] == plugin.SourceHash {
|
|
nextOverrides = append(nextOverrides, current)
|
|
nextHashes[plugin.Key] = plugin.SourceHash
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=reuse",
|
|
plugin.Key,
|
|
plugin.Version,
|
|
)
|
|
continue
|
|
}
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_start",
|
|
plugin.Key,
|
|
plugin.Version,
|
|
)
|
|
var compiled *jsplugin.LoadedPlugin
|
|
source, compileErr := model.GetTaskPluginSource(plugin.Id)
|
|
if compileErr != nil {
|
|
compileErr = fmt.Errorf("load source: %w", compileErr)
|
|
} else {
|
|
compiled, compileErr = jsplugin.CompilePlugin(string(source), jsplugin.Options{Key: plugin.Key, Version: plugin.Version})
|
|
}
|
|
if compileErr != nil {
|
|
retainedIncumbent := false
|
|
if current := currentOverrides[plugin.Key]; current != nil {
|
|
nextOverrides = append(nextOverrides, current)
|
|
retainedIncumbent = true
|
|
if currentHash := taskPluginSyncState.hashes[plugin.Key]; currentHash != "" {
|
|
nextHashes[plugin.Key] = currentHash
|
|
}
|
|
}
|
|
taskPluginSyncState.errors[plugin.Key] = compileErr.Error()
|
|
common.SysError(common.LogText("compile task plugin %s@%s: %v", plugin.Key, plugin.Version, compileErr))
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_failed retained_incumbent=%t",
|
|
plugin.Key,
|
|
plugin.Version,
|
|
retainedIncumbent,
|
|
)
|
|
continue
|
|
}
|
|
nextOverrides = append(nextOverrides, compiled)
|
|
nextHashes[plugin.Key] = plugin.SourceHash
|
|
delete(taskPluginSyncState.errors, plugin.Key)
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=plugin plugin=%q version=%q action=compile_success",
|
|
plugin.Key,
|
|
plugin.Version,
|
|
)
|
|
}
|
|
if err = jsplugin.DefaultRegistry.ReplaceOverrides(nextOverrides); err != nil {
|
|
syncErr := fmt.Errorf("publish task plugin generation: %w", err)
|
|
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
|
|
Status: "failed",
|
|
AttemptedAt: time.Now(),
|
|
Generation: jsplugin.DefaultRegistry.Generation().Number,
|
|
DatabaseRevision: databaseSnapshot.Revision,
|
|
Error: syncErr.Error(),
|
|
}
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=failed stage=publish retained_generation=%d retained_generation_active=true database_revision=%q elapsed_ms=%d",
|
|
jsplugin.DefaultRegistry.Generation().Number,
|
|
databaseSnapshot.Revision,
|
|
time.Since(started).Milliseconds(),
|
|
)
|
|
return syncErr
|
|
}
|
|
taskPluginSyncState.hashes = nextHashes
|
|
for key := range taskPluginSyncState.errors {
|
|
if !seen[key] {
|
|
delete(taskPluginSyncState.errors, key)
|
|
}
|
|
}
|
|
pluginErrors := jsplugin.DefaultRegistry.RoutingErrors()
|
|
maps.Copy(pluginErrors, taskPluginSyncState.errors)
|
|
pluginErrorCount := len(pluginErrors)
|
|
status := "success"
|
|
if pluginErrorCount > 0 {
|
|
status = "partial"
|
|
}
|
|
taskPluginSyncState.lastRebuild = taskPluginRebuildOutcome{
|
|
Status: status,
|
|
AttemptedAt: time.Now(),
|
|
Generation: jsplugin.DefaultRegistry.Generation().Number,
|
|
DatabaseRevision: databaseSnapshot.Revision,
|
|
PluginErrorCount: pluginErrorCount,
|
|
}
|
|
logger.LogDebug(
|
|
ctx,
|
|
"task_plugin subsystem=sync event=complete database_revision=%q previous_generation=%d generation=%d status=%q active_overrides=%d plugin_errors=%d elapsed_ms=%d",
|
|
databaseSnapshot.Revision,
|
|
generationBefore,
|
|
jsplugin.DefaultRegistry.Generation().Number,
|
|
status,
|
|
len(jsplugin.DefaultRegistry.ActiveOverridePlugins()),
|
|
pluginErrorCount,
|
|
time.Since(started).Milliseconds(),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func SyncTaskPluginsOnce() {
|
|
if err := syncTaskPluginsOnce(); err != nil {
|
|
common.SysError(err.Error())
|
|
}
|
|
}
|
|
|
|
func SyncTaskPlugins() {
|
|
SyncTaskPluginsOnce()
|
|
for range time.NewTicker(30 * time.Second).C {
|
|
SyncTaskPluginsOnce()
|
|
}
|
|
}
|