diff --git a/controller/channel.go b/controller/channel.go
index 3187123a02..b77e97d45b 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -472,11 +472,19 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
if len(pluginKey) > 30 {
return fmt.Errorf("task plugin key must not exceed 30 characters")
}
- if _, ok := jsplugin.DefaultRegistry.Get(pluginKey); !ok {
+ plugin, ok := jsplugin.DefaultRegistry.Get(pluginKey)
+ if !ok {
return fmt.Errorf("task plugin %q is not registered", pluginKey)
}
if channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "" {
- return fmt.Errorf("base URL is required for task plugin channels")
+ // The plugin default is persisted onto the channel instead of being
+ // resolved per request, so the destination host stays an auditable
+ // channel property that only an administrator edit can change.
+ if plugin.Meta.BaseURL == "" {
+ return fmt.Errorf("base URL is required for task plugin channels")
+ }
+ defaultBaseURL := plugin.Meta.BaseURL
+ channel.BaseURL = &defaultBaseURL
}
}
@@ -625,6 +633,9 @@ func AddChannel(c *gin.Context) {
return
}
+ baseURLFromPluginDefault := addChannelRequest.Channel != nil &&
+ addChannelRequest.Channel.Type == constant.ChannelTypeTaskPlugin &&
+ (addChannelRequest.Channel.BaseURL == nil || strings.TrimSpace(*addChannelRequest.Channel.BaseURL) == "")
// 使用统一的校验函数
if err := validateChannel(addChannelRequest.Channel, true); err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -709,11 +720,15 @@ func AddChannel(c *gin.Context) {
common.ApiError(c, err)
return
}
- recordManageAudit(c, "channel.create", map[string]interface{}{
+ createAudit := map[string]interface{}{
"name": addChannelRequest.Channel.Name,
"type": addChannelRequest.Channel.Type,
"count": len(channels),
- })
+ }
+ if baseURLFromPluginDefault {
+ createAudit["base_url_source"] = "plugin_default"
+ }
+ recordManageAudit(c, "channel.create", createAudit)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -981,6 +996,8 @@ func UpdateChannel(c *gin.Context) {
return
}
+ baseURLFromPluginDefault := channel.Type == constant.ChannelTypeTaskPlugin &&
+ (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "")
// 使用统一的校验函数
if err := validateChannel(&channel.Channel, false); err != nil {
c.JSON(http.StatusOK, gin.H{
@@ -1126,11 +1143,15 @@ func UpdateChannel(c *gin.Context) {
if channel.Key != "" && channel.Key != originChannel.Key {
changedFields = append(changedFields, "key")
}
- recordManageAudit(c, "channel.update", map[string]interface{}{
+ updateAudit := map[string]interface{}{
"id": channel.Id,
"name": channel.Name,
"changed_fields": changedFields,
- })
+ }
+ if baseURLFromPluginDefault {
+ updateAudit["base_url_source"] = "plugin_default"
+ }
+ recordManageAudit(c, "channel.update", updateAudit)
channel.Key = ""
clearChannelInfo(&channel.Channel)
c.JSON(http.StatusOK, gin.H{
diff --git a/controller/channel_task_plugin_bind_test.go b/controller/channel_task_plugin_bind_test.go
index 192e57fb9e..6725399a1a 100644
--- a/controller/channel_task_plugin_bind_test.go
+++ b/controller/channel_task_plugin_bind_test.go
@@ -129,3 +129,38 @@ export function parseTaskResult() { return {}; }
assert.Contains(t, recorder.Body.String(), "task plugin channels require the task_plugin.bind permission")
assert.Contains(t, recorder.Body.String(), `"success":false`)
}
+
+func TestAddChannelTaskPluginPersistsPluginDefaultBaseURLAndAuditsSource(t *testing.T) {
+ setupTaskPluginBindChannelTest(t)
+ for key, baseURLField := range map[string]string{"bind-default-url": `baseUrl: "http://10.0.0.5:8000/",`, "bind-no-default": ""} {
+ source := fmt.Sprintf(`
+export const meta = {apiVersion: 1, key: %q, name: "Bind", version: "1.0.0", author: {name: "Test"}, %s models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`, key, baseURLField)
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
+ }
+ body := func(pluginKey string) string {
+ return fmt.Sprintf(`{"mode":"single","channel":{"type":61,"name":"%s","key":"sk","models":"doc","group":"default","setting":"{\"task_plugin_key\":\"%s\"}"}}`, pluginKey, pluginKey)
+ }
+
+ noDefault := postAddChannel(t, 1, common.RoleRootUser, body("bind-no-default"))
+ assert.Contains(t, noDefault.Body.String(), "base URL is required for task plugin channels")
+
+ filled := postAddChannel(t, 1, common.RoleRootUser, body("bind-default-url"))
+ require.Contains(t, filled.Body.String(), `"success":true`)
+ var created model.Channel
+ require.NoError(t, model.DB.Where("name = ?", "bind-default-url").First(&created).Error)
+ require.NotNil(t, created.BaseURL)
+ assert.Equal(t, "http://10.0.0.5:8000", *created.BaseURL, "the normalized plugin default is stored on the channel row")
+
+ var audits []model.AuditLog
+ require.NoError(t, model.LOG_DB.Where("action = ?", "channel.create").Find(&audits).Error)
+ encoded, err := common.Marshal(audits)
+ require.NoError(t, err)
+ assert.Contains(t, string(encoded), `"base_url_source":"plugin_default"`)
+}
diff --git a/controller/channel_task_plugin_validation_test.go b/controller/channel_task_plugin_validation_test.go
index 6dfe065040..88b0f2113b 100644
--- a/controller/channel_task_plugin_validation_test.go
+++ b/controller/channel_task_plugin_validation_test.go
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/jsplugin"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -39,3 +40,30 @@ export function parseTaskResult() { return {}; }
channel.BaseURL = nil
require.ErrorContains(t, validateChannel(channel, false), "base URL is required")
}
+
+func TestValidateTaskPluginChannelFillsPluginDefaultBaseURL(t *testing.T) {
+ source := `
+export const meta = {apiVersion: 1, key: "channel-default-url", name: "Default URL", version: "1.0.0", author: {name: "Test"}, baseUrl: "http://127.0.0.1:8000/", models: ["doc"], fetchMode: "per_task"};
+export function buildSubmitRequest() { return {}; }
+export function parseSubmitResponse() { return {}; }
+export function buildQueryRequest() { return {}; }
+export function parseTaskResult() { return {}; }
+`
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister("channel-default-url") })
+ bound := `{"task_plugin_key":"channel-default-url"}`
+
+ empty := " "
+ for _, baseURL := range []*string{nil, &empty} {
+ channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Key: "sk", Setting: &bound, BaseURL: baseURL}
+ require.NoError(t, validateChannel(channel, true))
+ require.NotNil(t, channel.BaseURL)
+ assert.Equal(t, "http://127.0.0.1:8000", *channel.BaseURL, "normalized plugin default is persisted onto the channel")
+ }
+
+ explicit := "https://override.example.com"
+ channel := &model.Channel{Type: constant.ChannelTypeTaskPlugin, Setting: &bound, BaseURL: &explicit}
+ require.NoError(t, validateChannel(channel, false))
+ assert.Equal(t, explicit, *channel.BaseURL, "an administrator value is never replaced by the plugin default")
+}
diff --git a/controller/task_plugin.go b/controller/task_plugin.go
index 371ba8b71b..50eb2511d0 100644
--- a/controller/task_plugin.go
+++ b/controller/task_plugin.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net/http"
"net/url"
"sort"
"strings"
@@ -30,6 +31,9 @@ type taskPluginUploadRequest struct {
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) {
@@ -59,6 +63,13 @@ func UploadTaskPlugin(c *gin.Context) {
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
@@ -72,7 +83,7 @@ func UploadTaskPlugin(c *gin.Context) {
plugin := model.TaskPlugin{
Key: loaded.Meta.Key, APIVersion: loaded.Meta.APIVersion, Version: loaded.Meta.Version,
Source: request.Source, SourceHash: fmt.Sprintf("%x", sha256.Sum256([]byte(request.Source))),
- Enabled: enabled, Remark: request.Remark,
+ Icon: icon, Enabled: enabled, Remark: request.Remark,
}
if err = model.SaveTaskPlugin(&plugin); err != nil {
common.ApiError(c, err)
@@ -82,7 +93,7 @@ func UploadTaskPlugin(c *gin.Context) {
common.ApiError(c, err)
return
}
- common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"})
+ common.ApiSuccess(c, taskPluginDetail{Plugin: &plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
}
func GetTaskPluginVersions(c *gin.Context) {
@@ -100,6 +111,7 @@ type taskPluginListItem struct {
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"`
@@ -179,6 +191,7 @@ func ListTaskPlugins(c *gin.Context) {
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"
@@ -199,6 +212,7 @@ func ListTaskPlugins(c *gin.Context) {
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)))
@@ -221,7 +235,12 @@ func ListTaskPlugins(c *gin.Context) {
}
items = append(items, item)
}
- sort.Slice(items, func(i, j int) bool { return items[i].Meta.Key < items[j].Meta.Key })
+ 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)
}
@@ -273,10 +292,43 @@ func GetTaskPluginRuntime(c *gin.Context) {
}
type taskPluginDetail struct {
- Plugin *model.TaskPlugin `json:"plugin,omitempty"`
- Meta jsplugin.Meta `json:"meta"`
- Source string `json:"source"`
- Layer string `json:"layer"`
+ 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
, 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 = 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) {
@@ -289,7 +341,7 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiErrorMsg(c, compileErr.Error())
return
}
- common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override"})
+ common.ApiSuccess(c, taskPluginDetail{Plugin: plugin, Meta: loaded.Meta, Source: plugin.Source, Layer: "override", HasIcon: plugin.HasIcon()})
return
}
if !errors.Is(err, gorm.ErrRecordNotFound) || version != "" {
@@ -306,7 +358,8 @@ func GetTaskPlugin(c *gin.Context) {
common.ApiError(c, err)
return
}
- common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory"})
+ _, _, hasIcon := plugins.Icon(key)
+ common.ApiSuccess(c, taskPluginDetail{Meta: loaded.Meta, Source: source, Layer: "factory", HasIcon: hasIcon})
}
type taskPluginDryRunRequest struct {
@@ -590,15 +643,34 @@ func GetTaskPluginOptions(c *gin.Context) {
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,
- "models": meta.Models,
- "usageSchema": meta.UsageSchema,
+ "key": meta.Key,
+ "name": meta.Name,
+ "icon": meta.Icon,
+ "hasIcon": hasIcon,
+ "baseUrl": meta.BaseURL,
+ "sortPriority": meta.SortPriority,
+ "website": meta.Website,
+ "models": meta.Models,
+ "usageSchema": meta.UsageSchema,
})
}
}
- sort.Slice(options, func(i, j int) bool { return options[i]["key"].(string) < options[j]["key"].(string) })
+ 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)
}
diff --git a/controller/task_plugin_test.go b/controller/task_plugin_test.go
index 580533b2b2..ddf5673f92 100644
--- a/controller/task_plugin_test.go
+++ b/controller/task_plugin_test.go
@@ -1,7 +1,9 @@
package controller
import (
+ "bytes"
"crypto/sha256"
+ "encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
@@ -356,11 +358,13 @@ func TestMasterSwitchEmptiesOptionsAndKeepsList(t *testing.T) {
assert.Equal(t, "kling", item.Meta.Key)
}
-func TestGetTaskPluginOptionsIncludesUsageSchema(t *testing.T) {
+func TestGetTaskPluginOptionsIncludesUsageSchemaIconAndBaseURL(t *testing.T) {
+ setupTaskPluginControllerTest(t)
const key = "usage-options-probe"
source := `
export const meta = {
apiVersion: 1, key: "usage-options-probe", name: "Usage Options", version: "1.0.0", author: {name: "Test"},
+ icon: "text:UO", baseUrl: "http://localhost:9000/",
models: ["usage-options-model"], fetchMode: "per_task",
usageSchema: {seconds: {type: "number", unit: "second", description: "Generated media duration."}}
};
@@ -383,6 +387,8 @@ export function parseTaskResult() { return {}; }
Success bool `json:"success"`
Data []struct {
Key string `json:"key"`
+ Icon string `json:"icon"`
+ BaseURL string `json:"baseUrl"`
UsageSchema map[string]jsplugin.UsageFieldSchema `json:"usageSchema"`
} `json:"data"`
}
@@ -394,6 +400,8 @@ export function parseTaskResult() { return {}; }
}
assert.Equal(t, "second", option.UsageSchema["seconds"].Unit)
assert.Equal(t, "Generated media duration.", option.UsageSchema["seconds"].Description["en"])
+ assert.Equal(t, "text:UO", option.Icon)
+ assert.Equal(t, "http://localhost:9000", option.BaseURL, "the drawer prefills the normalized plugin default")
return
}
t.Fatal("task plugin option not found")
@@ -935,18 +943,22 @@ export function parseTaskResult() { return {}; }
}
func TestDeletePureFactoryPluginIsRejected(t *testing.T) {
- setupTaskPluginControllerTest(t)
- recorder := httptest.NewRecorder()
- context, _ := gin.CreateTestContext(recorder)
- context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
- context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0", nil)
+ for _, query := range []string{"", "?force=true"} {
+ t.Run(query, func(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: "kling"}, {Key: "version", Value: "1.0.0"}}
+ context.Request = httptest.NewRequest(http.MethodDelete, "/api/plugin/task/kling/versions/1.0.0"+query, nil)
- DeleteTaskPluginVersion(context)
+ DeleteTaskPluginVersion(context)
- assert.Contains(t, recorder.Body.String(), `"success":false`)
- assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
- _, ok := jsplugin.DefaultRegistry.Get("kling")
- assert.True(t, ok)
+ assert.Contains(t, recorder.Body.String(), `"success":false`)
+ assert.Contains(t, recorder.Body.String(), "factory plugins cannot be deleted")
+ _, ok := jsplugin.DefaultRegistry.Get("kling")
+ assert.True(t, ok)
+ })
+ }
}
func TestUploadTaskPluginSourceSha256(t *testing.T) {
@@ -1118,3 +1130,106 @@ func TestUpdateTaskPluginMarketplaceSourcesValidation(t *testing.T) {
})
}
}
+
+func TestTaskPluginDisplayOrderAndMetadata(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ const website = "https://example.com/plugins"
+ keys := []string{"display-low", "display-zero", "display-beta", "display-alpha"}
+ priorities := []int{-10, 0, 50, 50}
+ for i, key := range keys {
+ source := strings.Replace(taskPluginControllerTestSource(key, "1.0.0"), "apiVersion: 1,", fmt.Sprintf("apiVersion: 1, sortPriority: %d, website: %q,", priorities[i], website), 1)
+ _, err := jsplugin.DefaultRegistry.Register(source, jsplugin.Options{})
+ require.NoError(t, err)
+ cleanupTaskPluginControllerRuntime(t, key)
+ require.NoError(t, model.SaveTaskPlugin(&model.TaskPlugin{Key: key, Version: "1.0.0", APIVersion: 1, Source: source, Enabled: true}))
+ }
+ for _, endpoint := range []string{"list", "options"} {
+ t.Run(endpoint, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodGet, "/", nil)
+ var metas []jsplugin.Meta
+ if endpoint == "list" {
+ ListTaskPlugins(context)
+ var response struct {
+ Success bool
+ Data []taskPluginListItem
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ for _, item := range response.Data {
+ metas = append(metas, item.Meta)
+ }
+ } else {
+ GetTaskPluginOptions(context)
+ var response struct {
+ Success bool
+ Data []jsplugin.Meta
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Success)
+ metas = response.Data
+ }
+ var ordered []string
+ for _, meta := range metas {
+ if strings.HasPrefix(meta.Key, "display-") {
+ ordered = append(ordered, meta.Key)
+ assert.Equal(t, website, meta.Website)
+ }
+ }
+ assert.Equal(t, []string{"display-alpha", "display-beta", "display-zero", "display-low"}, ordered)
+ })
+ }
+}
+
+func TestUploadTaskPluginStoresSidecarIconAndServesIt(t *testing.T) {
+ setupTaskPluginControllerTest(t)
+ const key = "icon-sidecar"
+ source := taskPluginControllerTestSource(key, "1.0.0")
+ t.Cleanup(func() { jsplugin.DefaultRegistry.Unregister(key) })
+ svgIcon := "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(``))
+ upload := func(icon string) *httptest.ResponseRecorder {
+ body, err := common.Marshal(map[string]any{"source": source, "icon": icon})
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Request = httptest.NewRequest(http.MethodPost, "/api/plugin/task", bytes.NewReader(body))
+ context.Request.Header.Set("Content-Type", "application/json")
+ UploadTaskPlugin(context)
+ return recorder
+ }
+
+ rejected := upload("data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(``)))
+ assert.Contains(t, rejected.Body.String(), `"success":false`)
+ assert.Contains(t, rejected.Body.String(), "script")
+ _, err := model.GetTaskPluginVersion(key, "1.0.0")
+ require.ErrorIs(t, err, gorm.ErrRecordNotFound, "a rejected icon must not store the plugin")
+
+ accepted := upload(svgIcon)
+ require.Contains(t, accepted.Body.String(), `"success":true`)
+ assert.Contains(t, accepted.Body.String(), `"has_icon":true`)
+ assert.NotContains(t, accepted.Body.String(), "base64,", "icon bytes never travel inside detail JSON")
+ stored, err := model.GetTaskPluginVersion(key, "1.0.0")
+ require.NoError(t, err)
+ assert.Equal(t, svgIcon, stored.Icon)
+
+ item := listTaskPluginItem(t, key)
+ assert.True(t, item.HasIcon)
+
+ recorder := httptest.NewRecorder()
+ context, _ := gin.CreateTestContext(recorder)
+ context.Params = gin.Params{{Key: "key", Value: key}}
+ context.Request = httptest.NewRequest(http.MethodGet, "/api/plugin/task/"+key+"/icon", nil)
+ GetTaskPluginIcon(context)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ assert.Equal(t, "image/svg+xml", recorder.Header().Get("Content-Type"))
+ assert.Equal(t, "nosniff", recorder.Header().Get("X-Content-Type-Options"))
+ assert.Contains(t, recorder.Body.String(), ");
-export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText};
+export type UsageFieldSchema = {type: "number"; unit: "second" | "count" | "token" | "credit"; description?: LocalizedText} | {type: "boolean"; description?: LocalizedText} | {enum: readonly string[]; description?: LocalizedText; enumLabels?: Readonly>};
export type UsageExample = {label: string; facts: Readonly>};
-export interface Meta {apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
+export interface Meta {sortPriority?: number; website?: string; apiVersion: 1; key: string; name: string; icon?: string; description?: LocalizedText; version: string; author: {name: string; url?: string}; baseUrl?: string; channelTypes?: readonly number[]; models: readonly string[]; fetchMode: "per_task" | "batch"; allowedHosts?: readonly string[]; routes?: readonly NativeRoute[]; protocols?: readonly ProtocolClaim[]; usageSchema?: Readonly>; usageExamples?: readonly UsageExample[]; auth?: "none" | "api_key" | "vertex_oauth" | {type: "none" | "api_key" | "oauth2_jwt"}}
export interface TaskView {task_id: string; status: string; progress?: string; fail_reason?: string; created_at?: number; updated_at?: number; data?: unknown; properties?: Record}
export interface DriverContext {requestBody: unknown; requestHeaders: Readonly>; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; files: readonly FileReference[]; publicTaskId: string; originTasks?: readonly {taskId: string; upstreamTaskId: string; action: string; status: string; data: unknown}[]}
export interface TaskQueryContext {taskId: string; publicTaskId: string; action: string; model: string; upstreamModel: string; baseUrl: string; apiKey?: string; authHeader: string; auth?: unknown; data: unknown; state: unknown}
diff --git a/docs/plugin-api/v1.md b/docs/plugin-api/v1.md
index 1c8137811a..ef512f76a2 100644
--- a/docs/plugin-api/v1.md
+++ b/docs/plugin-api/v1.md
@@ -34,18 +34,28 @@ Enabled uploads pre-flight the candidate against the live routing generation and
`endpoints`, `routes[].renderer`, global `resolveRequest`, global `renderError`, and global `renderers` are rejected. `parseSubmitResponse` returns only `{taskId, taskData, immediate?, state?}`; `clientResponse` is rejected.
-`icon` is an optional LobeHub icon name string (for example `Sora.Color`). The values `text` and `text: