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: