mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat(skills): configure upload size limits
This commit is contained in:
1 parent
5d62877550
commit
1296031d5a
33 files changed
+597
-58
No files matched your search
+1
-1
@@ -383,7 +383,7 @@ func runGateway() {
|
||||
httpapi.InitGatewayNoAuthFallbackAllowed(config.GatewayNoAuthFallbackAllowed(cfg.Gateway))
|
||||
exportTokenStore := httpapi.InitExportTokenStore()
|
||||
defer exportTokenStore.Stop()
|
||||
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister, usageCapSvc)
|
||||
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister, usageCapSvc, cfg.Skills)
|
||||
|
||||
// Wire dependencies for system prompt preview parity.
|
||||
if agentsH != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
@@ -10,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages).
|
||||
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, usageCapSvc *usagecaps.Service) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) {
|
||||
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister, usageCapSvc *usagecaps.Service, skillUploadConfig config.SkillsConfig) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) {
|
||||
var agentsH *httpapi.AgentsHandler
|
||||
var skillsH *httpapi.SkillsHandler
|
||||
var tracesH *httpapi.TracesHandler
|
||||
@@ -38,6 +39,10 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
|
||||
if len(dirs) > 0 {
|
||||
skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants)
|
||||
skillsH.SetDB(stores.DB)
|
||||
skillsH.SetUploadLimitConfig(skillUploadConfig)
|
||||
if stores.SystemConfigs != nil {
|
||||
skillsH.SetSystemConfigStore(stores.SystemConfigs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,3 +46,17 @@ func TestSeedConfigForContextPersistsZeroInboundDebounce(t *testing.T) {
|
||||
t.Fatalf("gateway.inbound_debounce_ms = %q, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedConfigForContextDoesNotCreateSkillUploadTenantOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sc := &captureSystemConfigStore{data: map[string]string{}}
|
||||
cfg := config.Default()
|
||||
cfg.Skills.MaxUploadSizeMB = 64
|
||||
|
||||
seedConfigForContext(store.WithTenantID(context.Background(), store.MasterTenantID), sc, cfg, false)
|
||||
|
||||
if _, ok := sc.data[config.SkillMaxUploadSizeSystemConfigKey]; ok {
|
||||
t.Fatalf("%s should not be seeded; missing key lets SKILL.md frontmatter override global config", config.SkillMaxUploadSizeSystemConfigKey)
|
||||
}
|
||||
}
|
||||
@@ -442,7 +442,7 @@ All CRUD endpoints require `Authorization: Bearer <token>` and `X-GoClaw-User-Id
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/v1/skills` | List skills |
|
||||
| POST | `/v1/skills/upload` | Upload skill ZIP (max 20 MB) |
|
||||
| POST | `/v1/skills/upload` | Upload skill ZIP (configurable, default 20 MB, max 500 MB) |
|
||||
| DELETE | `/v1/skills/{id}` | Delete a skill |
|
||||
|
||||
**Traces** (`/v1/traces`):
|
||||
|
||||
@@ -183,7 +183,7 @@ Unlike the HTTP upload handler, the tool does **not** archive the skill on missi
|
||||
| `..` in relative path | Skip (prevent traversal) |
|
||||
| Symlinks | Skip (prevent escape) |
|
||||
| System artifacts | Skip (`.DS_Store`, `__MACOSX`, `Thumbs.db`, etc.) |
|
||||
| Total dir size > 20 MB | Reject with error |
|
||||
| Total upload size exceeds configured limit | Reject with error. Default is 20 MB; configurable via `skills.max_upload_size_mb`, `GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, tenant `skills.max_upload_size_mb`, or SKILL.md frontmatter `max_upload_size_mb`; clamped to 1-500 MB. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-1
@@ -321,7 +321,7 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `GET` | `/v1/skills` | List all skills |
|
||||
| `POST` | `/v1/skills/upload` | Upload ZIP with SKILL.md (20 MB limit) |
|
||||
| `POST` | `/v1/skills/upload` | Upload ZIP with SKILL.md (configurable 20 MB default, 1-500 MB range) |
|
||||
| `GET` | `/v1/skills/{id}` | Get skill details |
|
||||
| `PUT` | `/v1/skills/{id}` | Update skill metadata |
|
||||
| `DELETE` | `/v1/skills/{id}` | Delete skill (not system skills) |
|
||||
@@ -331,6 +331,12 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea
|
||||
|
||||
### Skill Grants
|
||||
|
||||
Skill upload size is enforced per ZIP file. The effective limit resolves in this order:
|
||||
tenant `system_configs["skills.max_upload_size_mb"]`, then `SKILL.md` frontmatter
|
||||
`max_upload_size_mb`, then config/env `skills.max_upload_size_mb` /
|
||||
`GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, then the default 20 MB. Values are clamped
|
||||
to 1-500 MB.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent |
|
||||
|
||||
@@ -450,7 +450,7 @@ System skills (`is_system=true`) cannot be modified through any path.
|
||||
| Symlink detection | `filepath.WalkDir` + `d.Type()&os.ModeSymlink` check |
|
||||
| Path traversal | `strings.Contains(rel, "..")` rejection |
|
||||
| Content size limit | 100KB max for SKILL.md content |
|
||||
| Companion size limit | 20MB max total for companion files (scripts, assets) |
|
||||
| Companion size limit | Configurable per ZIP upload; default 20MB, clamped to 1-500MB |
|
||||
| Soft-delete | Files moved to `.trash/`, never hard-deleted |
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,17 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
## 2026-05-24
|
||||
|
||||
### Configurable skill upload limits
|
||||
|
||||
**Features**
|
||||
|
||||
- Added configurable skill ZIP upload limits with config/env, SKILL.md frontmatter, and tenant system setting support.
|
||||
- Added dashboard settings and dynamic upload validation so the Web UI follows the tenant limit instead of hardcoding 20MB.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Added backend coverage for limit precedence, clamping, oversized rejection, and frontend coverage for parameterized upload validation.
|
||||
|
||||
### CLI environment variable visibility
|
||||
|
||||
**Features**
|
||||
|
||||
@@ -47,6 +47,7 @@ type Config struct {
|
||||
Providers ProvidersConfig `json:"providers"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Skills SkillsConfig `json:"skills"`
|
||||
Sessions SessionsConfig `json:"sessions"`
|
||||
Database DatabaseConfig `json:"database"`
|
||||
Tts TtsConfig `json:"tts"`
|
||||
@@ -129,7 +130,37 @@ type DatabaseConfig struct {
|
||||
|
||||
// SkillsConfig configures the skills storage system.
|
||||
type SkillsConfig struct {
|
||||
StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/)
|
||||
StorageDir string `json:"storage_dir,omitempty"` // directory for skill content (default: dataDir/skills-store/)
|
||||
MaxUploadSizeMB int `json:"max_upload_size_mb,omitempty"` // per-file ZIP upload limit
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultSkillMaxUploadSizeMB = 20
|
||||
MinSkillMaxUploadSizeMB = 1
|
||||
MaxSkillMaxUploadSizeMB = 500
|
||||
|
||||
SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb"
|
||||
)
|
||||
|
||||
func ClampSkillMaxUploadSizeMB(value int) int {
|
||||
if value == 0 {
|
||||
return DefaultSkillMaxUploadSizeMB
|
||||
}
|
||||
if value < MinSkillMaxUploadSizeMB {
|
||||
return MinSkillMaxUploadSizeMB
|
||||
}
|
||||
if value > MaxSkillMaxUploadSizeMB {
|
||||
return MaxSkillMaxUploadSizeMB
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (c SkillsConfig) EffectiveMaxUploadSizeMB() int {
|
||||
return ClampSkillMaxUploadSizeMB(c.MaxUploadSizeMB)
|
||||
}
|
||||
|
||||
func (c SkillsConfig) EffectiveMaxUploadSizeBytes() int64 {
|
||||
return int64(c.EffectiveMaxUploadSizeMB()) << 20
|
||||
}
|
||||
|
||||
// AgentBinding maps a channel/peer pattern to a specific agent.
|
||||
@@ -479,6 +510,7 @@ func (c *Config) ReplaceFrom(src *Config) {
|
||||
c.Providers = src.Providers
|
||||
c.Gateway = src.Gateway
|
||||
c.Tools = src.Tools
|
||||
c.Skills = src.Skills
|
||||
c.Sessions = src.Sessions
|
||||
c.Database = src.Database
|
||||
c.Tts = src.Tts
|
||||
|
||||
@@ -107,6 +107,9 @@ func Default() *Config {
|
||||
},
|
||||
RateLimitPerHour: 150,
|
||||
},
|
||||
Skills: SkillsConfig{
|
||||
MaxUploadSizeMB: DefaultSkillMaxUploadSizeMB,
|
||||
},
|
||||
Sessions: SessionsConfig{},
|
||||
}
|
||||
}
|
||||
@@ -234,6 +237,11 @@ func (c *Config) applyEnvOverrides() {
|
||||
c.Gateway.Port = port
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB"); v != "" {
|
||||
if mb, err := strconv.Atoi(v); err == nil {
|
||||
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(mb)
|
||||
}
|
||||
}
|
||||
|
||||
// Database
|
||||
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
|
||||
|
||||
@@ -24,6 +24,9 @@ func TestDefault_SensibleDefaults(t *testing.T) {
|
||||
if cfg.Agents.Defaults.MaxToolIterations != DefaultMaxIterations {
|
||||
t.Fatalf("default max iterations: got %d", cfg.Agents.Defaults.MaxToolIterations)
|
||||
}
|
||||
if cfg.Skills.EffectiveMaxUploadSizeMB() != DefaultSkillMaxUploadSizeMB {
|
||||
t.Fatalf("default skill upload max: got %d, want %d", cfg.Skills.EffectiveMaxUploadSizeMB(), DefaultSkillMaxUploadSizeMB)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -103,6 +106,52 @@ func TestLoad_EnvVarOverrides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_SkillsMaxUploadSizeFromFileAndEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.json5")
|
||||
os.WriteFile(cfgPath, []byte(`{"skills":{"max_upload_size_mb":64}}`), 0644)
|
||||
|
||||
cfg, err := Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load error: %v", err)
|
||||
}
|
||||
if cfg.Skills.EffectiveMaxUploadSizeMB() != 64 {
|
||||
t.Fatalf("file skill upload max: got %d, want 64", cfg.Skills.EffectiveMaxUploadSizeMB())
|
||||
}
|
||||
|
||||
t.Setenv("GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB", "128")
|
||||
cfg, err = Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load with env error: %v", err)
|
||||
}
|
||||
if cfg.Skills.EffectiveMaxUploadSizeMB() != 128 {
|
||||
t.Fatalf("env skill upload max: got %d, want 128", cfg.Skills.EffectiveMaxUploadSizeMB())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsMaxUploadSizeClampAndSystemConfigOverlay(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Skills.MaxUploadSizeMB = 0
|
||||
if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != DefaultSkillMaxUploadSizeMB {
|
||||
t.Fatalf("zero upload max: got %d, want %d", got, DefaultSkillMaxUploadSizeMB)
|
||||
}
|
||||
|
||||
cfg.Skills.MaxUploadSizeMB = -10
|
||||
if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != MinSkillMaxUploadSizeMB {
|
||||
t.Fatalf("negative upload max: got %d, want %d", got, MinSkillMaxUploadSizeMB)
|
||||
}
|
||||
|
||||
cfg.Skills.MaxUploadSizeMB = 999
|
||||
if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != MaxSkillMaxUploadSizeMB {
|
||||
t.Fatalf("high upload max: got %d, want %d", got, MaxSkillMaxUploadSizeMB)
|
||||
}
|
||||
|
||||
cfg.ApplySystemConfigs(map[string]string{"skills.max_upload_size_mb": "77"})
|
||||
if got := cfg.Skills.EffectiveMaxUploadSizeMB(); got != 77 {
|
||||
t.Fatalf("system config upload max: got %d, want 77", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EnvVarOverrides_InvalidPort(t *testing.T) {
|
||||
t.Setenv("GOCLAW_PORT", "not-a-number")
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
|
||||
integer("tools.browser.max_pages", &c.Tools.Browser.MaxPages)
|
||||
boolValue("tools.browser.cookie_sync_enabled", &c.Tools.Browser.CookieSyncEnabled)
|
||||
|
||||
// Skills
|
||||
integer(SkillMaxUploadSizeSystemConfigKey, &c.Skills.MaxUploadSizeMB)
|
||||
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(c.Skills.MaxUploadSizeMB)
|
||||
|
||||
// TTS
|
||||
str("tts.provider", &c.Tts.Provider)
|
||||
str("tts.auto", &c.Tts.Auto)
|
||||
|
||||
@@ -265,14 +265,27 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client,
|
||||
"type": "object",
|
||||
"description": "Gateway server settings (host, port, token)",
|
||||
},
|
||||
"tools": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Tool configuration (browser, exec, web search)",
|
||||
},
|
||||
"sessions": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Session storage configuration",
|
||||
},
|
||||
"tools": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Tool configuration (browser, exec, web search)",
|
||||
},
|
||||
"skills": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Skill storage and upload settings",
|
||||
"properties": map[string]any{
|
||||
"max_upload_size_mb": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": config.MinSkillMaxUploadSizeMB,
|
||||
"maximum": config.MaxSkillMaxUploadSizeMB,
|
||||
"default": config.DefaultSkillMaxUploadSizeMB,
|
||||
"description": "Maximum skill ZIP upload size in MB",
|
||||
},
|
||||
},
|
||||
},
|
||||
"sessions": map[string]any{
|
||||
"type": "object",
|
||||
"description": "Session storage configuration",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,6 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const maxSkillUploadSize = 20 << 20 // 20 MB
|
||||
|
||||
var (
|
||||
aggregateInstallDeps = skills.AggregateMissingDeps
|
||||
installManagedDeps = skills.InstallDeps
|
||||
@@ -40,11 +38,13 @@ type SkillsHandler struct {
|
||||
tenantStore store.TenantStore
|
||||
db *sql.DB // for export/import direct queries
|
||||
uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex)
|
||||
uploadLimitCfg config.SkillsConfig
|
||||
systemConfigs store.SystemConfigStore
|
||||
}
|
||||
|
||||
// NewSkillsHandler creates a handler for skill management endpoints.
|
||||
func NewSkillsHandler(skills store.SkillManageStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore, tenantStore store.TenantStore) *SkillsHandler {
|
||||
return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore}
|
||||
return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore, uploadLimitCfg: config.SkillsConfig{MaxUploadSizeMB: config.DefaultSkillMaxUploadSizeMB}}
|
||||
}
|
||||
|
||||
// tenantSkillsDir returns the skills-store directory scoped to the requesting tenant.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -225,16 +223,3 @@ func (h *SkillsHandler) handleRevokeUser(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func readZipFile(f *zip.File) (string, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -43,7 +44,8 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSkillUploadSize)
|
||||
preParseLimitMB := h.resolvePreParseUploadLimitMB(r.Context())
|
||||
r.Body = http.MaxBytesReader(w, r.Body, skillUploadBodyBytes(preParseLimitMB))
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -70,6 +72,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
size, err := io.Copy(tmp, file)
|
||||
if err != nil {
|
||||
tmp.Close()
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, skillUploadTooLargeMessage(maxBytesErr.Limit, preParseLimitMB))})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save upload")})
|
||||
return
|
||||
}
|
||||
@@ -141,6 +148,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
name, description, slug, frontmatter := skills.ParseSkillFrontmatter(skillContent)
|
||||
uploadLimitMB := h.resolveSkillUploadLimitMB(r.Context(), frontmatter)
|
||||
if size > skillUploadLimitBytes(uploadLimitMB) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, skillUploadTooLargeMessage(size, uploadLimitMB))})
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "name in SKILL.md frontmatter")})
|
||||
return
|
||||
@@ -239,17 +251,11 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create skill file directory")})
|
||||
return
|
||||
}
|
||||
data, err := readZipFile(f)
|
||||
if err != nil {
|
||||
if err := copyZipFileToPath(f, destPath); err != nil {
|
||||
os.RemoveAll(destDir)
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "failed to read ZIP entry")})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(destPath, []byte(data), 0644); err != nil {
|
||||
os.RemoveAll(destDir)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to write skill files")})
|
||||
return
|
||||
}
|
||||
if cleanName == "SKILL.md" {
|
||||
wroteSkillMD = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
skillUploadMaxSizeConfigKey = config.SkillMaxUploadSizeSystemConfigKey
|
||||
skillUploadMultipartOverheadBytes = int64(1 << 20)
|
||||
)
|
||||
|
||||
func (h *SkillsHandler) SetUploadLimitConfig(cfg config.SkillsConfig) {
|
||||
h.uploadLimitCfg = cfg
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) SetSystemConfigStore(s store.SystemConfigStore) {
|
||||
h.systemConfigs = s
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) resolveSkillUploadLimitMB(ctx context.Context, frontmatter map[string]string) int {
|
||||
if mb, ok := h.resolveTenantSkillUploadLimitMB(ctx); ok {
|
||||
return mb
|
||||
}
|
||||
if mb, ok := parseSkillUploadLimitMB(frontmatter["max_upload_size_mb"]); ok {
|
||||
return config.ClampSkillMaxUploadSizeMB(mb)
|
||||
}
|
||||
return h.uploadLimitCfg.EffectiveMaxUploadSizeMB()
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) resolvePreParseUploadLimitMB(ctx context.Context) int {
|
||||
if mb, ok := h.resolveTenantSkillUploadLimitMB(ctx); ok {
|
||||
return mb
|
||||
}
|
||||
return config.MaxSkillMaxUploadSizeMB
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) resolveTenantSkillUploadLimitMB(ctx context.Context) (int, bool) {
|
||||
if h.systemConfigs == nil {
|
||||
return 0, false
|
||||
}
|
||||
raw, err := h.systemConfigs.Get(ctx, skillUploadMaxSizeConfigKey)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
mb, ok := parseSkillUploadLimitMB(raw)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return config.ClampSkillMaxUploadSizeMB(mb), true
|
||||
}
|
||||
|
||||
func parseSkillUploadLimitMB(raw string) (int, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
mb, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return mb, true
|
||||
}
|
||||
|
||||
func skillUploadLimitBytes(limitMB int) int64 {
|
||||
return int64(config.ClampSkillMaxUploadSizeMB(limitMB)) << 20
|
||||
}
|
||||
|
||||
func skillUploadBodyBytes(limitMB int) int64 {
|
||||
return skillUploadLimitBytes(limitMB) + skillUploadMultipartOverheadBytes
|
||||
}
|
||||
|
||||
func skillUploadTooLargeMessage(size int64, limitMB int) string {
|
||||
return fmt.Sprintf("skill ZIP size %s exceeds %d MB limit", formatUploadBytes(size), limitMB)
|
||||
}
|
||||
|
||||
func formatUploadBytes(size int64) string {
|
||||
const mb = int64(1 << 20)
|
||||
if size >= mb {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(mb))
|
||||
}
|
||||
return fmt.Sprintf("%d bytes", size)
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/skills"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
@@ -670,6 +672,65 @@ func TestHandleUpload_ReturnsGrantErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillUploadLimitMBPrecedenceAndClamp(t *testing.T) {
|
||||
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
handler, _, _, _ := newTestUploadHandler(t)
|
||||
handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 30})
|
||||
|
||||
if got := handler.resolveSkillUploadLimitMB(ctx, nil); got != 30 {
|
||||
t.Fatalf("global limit = %d, want 30", got)
|
||||
}
|
||||
if got := handler.resolveSkillUploadLimitMB(ctx, map[string]string{"max_upload_size_mb": "100"}); got != 100 {
|
||||
t.Fatalf("frontmatter limit = %d, want 100", got)
|
||||
}
|
||||
|
||||
handler.SetSystemConfigStore(&skillUploadSystemConfigStore{data: map[string]string{skillUploadMaxSizeConfigKey: "40"}})
|
||||
if got := handler.resolveSkillUploadLimitMB(ctx, map[string]string{"max_upload_size_mb": "100"}); got != 40 {
|
||||
t.Fatalf("tenant limit = %d, want 40", got)
|
||||
}
|
||||
|
||||
handler.SetSystemConfigStore(&skillUploadSystemConfigStore{data: map[string]string{skillUploadMaxSizeConfigKey: "999"}})
|
||||
if got := handler.resolveSkillUploadLimitMB(ctx, nil); got != config.MaxSkillMaxUploadSizeMB {
|
||||
t.Fatalf("clamped tenant limit = %d, want %d", got, config.MaxSkillMaxUploadSizeMB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsZipAboveConfiguredLimit(t *testing.T) {
|
||||
handler, _, ctx, _ := newTestUploadHandler(t)
|
||||
handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 1})
|
||||
|
||||
req := newZipUploadRequestWithBinary(t, ctx, map[string][]byte{
|
||||
"SKILL.md": []byte(skillMarkdown("Large Skill", "large-skill")),
|
||||
"large.bin": bytes.Repeat([]byte("x"), (1<<20)+1),
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleUpload(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "exceeds") || !strings.Contains(w.Body.String(), "1 MB") {
|
||||
t.Fatalf("body = %s, want configured upload limit error", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAllowsFrontmatterLimitAboveGlobalDefault(t *testing.T) {
|
||||
handler, _, ctx, _ := newTestUploadHandler(t)
|
||||
handler.SetUploadLimitConfig(config.SkillsConfig{MaxUploadSizeMB: 1})
|
||||
skillMD := "---\nname: Video Skill\nslug: video-skill\nmax_upload_size_mb: 2\n---\nSkill body\n"
|
||||
|
||||
req := newZipUploadRequestWithBinary(t, ctx, map[string][]byte{
|
||||
"SKILL.md": []byte(skillMD),
|
||||
"large.bin": bytes.Repeat([]byte("x"), (1<<20)+(128<<10)),
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleUpload(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, context.Context, string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -738,6 +799,43 @@ func newZipUploadRequestWithManagers(t *testing.T, ctx context.Context, files ma
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newZipUploadRequestWithBinary(t *testing.T, ctx context.Context, files map[string][]byte) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var zipBuf bytes.Buffer
|
||||
zw := zip.NewWriter(&zipBuf)
|
||||
for name, content := range files {
|
||||
header := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := w.Write(content); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, err := mw.CreateFormFile("file", "skill.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("multipart file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(zipBuf.Bytes()); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("multipart close: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newUploadManagerIDsFormRequest(t *testing.T, raw string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -770,6 +868,34 @@ type skillManageStoreStub struct {
|
||||
lastUpdates map[uuid.UUID]map[string]any
|
||||
}
|
||||
|
||||
type skillUploadSystemConfigStore struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (s *skillUploadSystemConfigStore) Get(_ context.Context, key string) (string, error) {
|
||||
if v, ok := s.data[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
|
||||
func (s *skillUploadSystemConfigStore) Set(_ context.Context, key, value string) error {
|
||||
if s.data == nil {
|
||||
s.data = map[string]string{}
|
||||
}
|
||||
s.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillUploadSystemConfigStore) Delete(_ context.Context, key string) error {
|
||||
delete(s.data, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *skillUploadSystemConfigStore) List(_ context.Context) (map[string]string, error) {
|
||||
return s.data, nil
|
||||
}
|
||||
|
||||
type skillGrantCall struct {
|
||||
SkillID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
const maxSkillMarkdownBytes = 100 << 10
|
||||
|
||||
var errSkillMarkdownTooLarge = errors.New("SKILL.md exceeds 100 KB limit")
|
||||
|
||||
func readZipFile(f *zip.File) (string, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(rc, maxSkillMarkdownBytes+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) > maxSkillMarkdownBytes {
|
||||
return "", errSkillMarkdownTooLarge
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func copyZipFileToPath(f *zip.File, destPath string) error {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
dst, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
_, err = io.Copy(dst, rc)
|
||||
return err
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export interface InitState {
|
||||
kgMinConfidence: string;
|
||||
bgProvider: string;
|
||||
bgModel: string;
|
||||
skillUploadMaxSize: string;
|
||||
}
|
||||
|
||||
export const DEFAULTS: InitState = {
|
||||
@@ -54,6 +55,7 @@ export const DEFAULTS: InitState = {
|
||||
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
|
||||
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
|
||||
bgProvider: "", bgModel: "",
|
||||
skillUploadMaxSize: "20",
|
||||
};
|
||||
|
||||
export function parseBool(v: string | undefined, fallback: boolean): boolean {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toast } from "@/stores/use-toast-store";
|
||||
import { EMBEDDING_MODELS, DEFAULT_EMBEDDING_MODELS, DEFAULTS, parseBool, type InitState } from "./system-settings-constants";
|
||||
import { SystemSettingsEmbeddingCard } from "./system-settings-embedding-card";
|
||||
import { SystemSettingsCompactionCard } from "./system-settings-compaction-card";
|
||||
import { SystemSettingsSkillsCard } from "./system-settings-skills-card";
|
||||
import { Eye, MessageSquareText, Brain } from "lucide-react";
|
||||
|
||||
interface SystemSettingsModalProps {
|
||||
@@ -60,6 +61,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
// Background Workers
|
||||
const [bgProvider, setBgProvider] = useState("");
|
||||
const [bgModel, setBgModel] = useState("");
|
||||
const [skillUploadMaxSize, setSkillUploadMaxSize] = useState("20");
|
||||
|
||||
const applyConfigs = useCallback((
|
||||
configs: Record<string, string>,
|
||||
@@ -76,6 +78,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
|
||||
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
|
||||
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
|
||||
skillUploadMaxSize: configs["skills.max_upload_size_mb"] ?? "20",
|
||||
};
|
||||
setInit(s);
|
||||
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
|
||||
@@ -83,6 +86,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
|
||||
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
|
||||
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
|
||||
setSkillUploadMaxSize(s.skillUploadMaxSize);
|
||||
resetEmb();
|
||||
}, [resetEmb]);
|
||||
|
||||
@@ -126,6 +130,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
|
||||
if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider;
|
||||
if (bgModel !== init.bgModel) updates["background.model"] = bgModel;
|
||||
if (skillUploadMaxSize !== init.skillUploadMaxSize) updates["skills.max_upload_size_mb"] = skillUploadMaxSize;
|
||||
for (const [key, value] of Object.entries(updates)) await http.put(`/v1/system-configs/${key}`, { value });
|
||||
const kgChanged = kgProvider !== init.kgProvider || kgModel !== init.kgModel || kgMinConfidence !== init.kgMinConfidence;
|
||||
if (kgChanged) {
|
||||
@@ -203,6 +208,11 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
|
||||
|
||||
<FeatureSwitchGroup title={t("ux.title")} description={t("ux.description")} items={uxItems} />
|
||||
|
||||
<SystemSettingsSkillsCard
|
||||
uploadMaxSize={skillUploadMaxSize}
|
||||
setUploadMaxSize={setSkillUploadMaxSize}
|
||||
/>
|
||||
|
||||
<SystemSettingsCompactionCard
|
||||
compProvider={compProvider} setCompProvider={setCompProvider}
|
||||
compModel={compModel} setCompModel={setCompModel}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface SystemSettingsSkillsCardProps {
|
||||
uploadMaxSize: string;
|
||||
setUploadMaxSize: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SystemSettingsSkillsCard({
|
||||
uploadMaxSize,
|
||||
setUploadMaxSize,
|
||||
}: SystemSettingsSkillsCardProps) {
|
||||
const { t } = useTranslation("system-settings");
|
||||
|
||||
return (
|
||||
<Card className="border-sky-200 dark:border-sky-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Upload className="h-4 w-4 text-sky-500" />
|
||||
{t("skills.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("skills.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="skillUploadMaxSize" className="text-sm font-medium">
|
||||
{t("skills.maxUploadSize")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("skills.maxUploadSizeHint")}</p>
|
||||
</div>
|
||||
<Input
|
||||
id="skillUploadMaxSize"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={uploadMaxSize}
|
||||
onChange={(e) => setUploadMaxSize(e.target.value)}
|
||||
className="w-24 shrink-0 text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Skills",
|
||||
"description": "Upload ZIP files containing SKILL.md with YAML frontmatter (name, description, slug).",
|
||||
"description": "Upload ZIP files containing SKILL.md with YAML frontmatter (name, description, slug). Max {{max}} MB per file.",
|
||||
"button": "Upload",
|
||||
"uploading": "Uploading...",
|
||||
"dropOrClick": "Click or drag & drop .zip files",
|
||||
@@ -114,7 +114,7 @@
|
||||
"onlyZip": "Only .zip files are accepted",
|
||||
"cancel": "Cancel",
|
||||
"done": "Done",
|
||||
"tooLarge": "File exceeds 20MB limit",
|
||||
"tooLarge": "File exceeds the configured upload limit",
|
||||
"invalidZip": "Invalid ZIP file",
|
||||
"noSkillMd": "ZIP must contain SKILL.md at root",
|
||||
"emptySkillMd": "SKILL.md is empty",
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"modelPlaceholder": "(default)",
|
||||
"info": "Used for vault enrichment (document summarization), consolidation (session summaries), and dreaming. Leave empty to fall back to the agent default provider."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Tenant skill package upload limits.",
|
||||
"maxUploadSize": "Max upload size",
|
||||
"maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Pending Message Compaction",
|
||||
"description": "Summarize long pending message history to stay within context limits.",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"upload": {
|
||||
"title": "Tải lên Skill",
|
||||
"description": "Tải lên tệp ZIP chứa SKILL.md với YAML frontmatter (name, description, slug).",
|
||||
"description": "Tải lên tệp ZIP chứa SKILL.md với YAML frontmatter (name, description, slug). Tối đa {{max}} MB mỗi tệp.",
|
||||
"button": "Tải lên",
|
||||
"uploading": "Đang tải lên...",
|
||||
"dropOrClick": "Nhấp hoặc kéo thả tệp .zip",
|
||||
@@ -72,7 +72,7 @@
|
||||
"onlyZip": "Chỉ chấp nhận tệp .zip",
|
||||
"cancel": "Hủy",
|
||||
"done": "Xong",
|
||||
"tooLarge": "Tệp vượt quá giới hạn 20MB",
|
||||
"tooLarge": "Tệp vượt quá giới hạn tải lên đã cấu hình",
|
||||
"invalidZip": "Tệp ZIP không hợp lệ",
|
||||
"noSkillMd": "ZIP phải chứa SKILL.md ở thư mục gốc",
|
||||
"emptySkillMd": "SKILL.md trống",
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"modelPlaceholder": "(mặc định)",
|
||||
"info": "Dùng cho vault enrichment (tóm tắt tài liệu), consolidation (tóm tắt phiên) và dreaming. Để trống sẽ fallback về agent default provider."
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills",
|
||||
"description": "Giới hạn tải lên gói skill theo tenant.",
|
||||
"maxUploadSize": "Dung lượng tải lên tối đa",
|
||||
"maxUploadSizeHint": "Giới hạn ZIP theo từng tệp, tính bằng MB. Khoảng cho phép: 1-500."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Nén tin nhắn chờ",
|
||||
"description": "Tóm tắt lịch sử tin nhắn chờ dài để nằm trong giới hạn context.",
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"upload": {
|
||||
"title": "上传Skill",
|
||||
"description": "上传包含带 YAML frontmatter (name, description, slug) 的 SKILL.md 的 ZIP 文件。",
|
||||
"description": "上传包含带 YAML frontmatter (name, description, slug) 的 SKILL.md 的 ZIP 文件。每个文件最大 {{max}} MB。",
|
||||
"button": "上传",
|
||||
"uploading": "上传中...",
|
||||
"dropOrClick": "点击或拖放 .zip 文件",
|
||||
@@ -72,7 +72,7 @@
|
||||
"onlyZip": "只接受 .zip 文件",
|
||||
"cancel": "取消",
|
||||
"done": "完成",
|
||||
"tooLarge": "文件超过 20MB 限制",
|
||||
"tooLarge": "文件超过配置的上传限制",
|
||||
"invalidZip": "无效的 ZIP 文件",
|
||||
"noSkillMd": "ZIP 必须包含根目录的 SKILL.md",
|
||||
"emptySkillMd": "SKILL.md 为空",
|
||||
|
||||
@@ -64,6 +64,12 @@
|
||||
"modelPlaceholder": "(默认)",
|
||||
"info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。"
|
||||
},
|
||||
"skills": {
|
||||
"title": "技能",
|
||||
"description": "租户技能包上传限制。",
|
||||
"maxUploadSize": "最大上传大小",
|
||||
"maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。"
|
||||
},
|
||||
"compaction": {
|
||||
"title": "待处理消息压缩",
|
||||
"description": "压缩长待处理消息历史以保持在上下文限制内。",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import {
|
||||
DEFAULT_SKILL_UPLOAD_SIZE_MB,
|
||||
normalizeSkillUploadSizeMB,
|
||||
} from "../lib/validate-skill-zip";
|
||||
|
||||
const SKILL_UPLOAD_LIMIT_KEY = "skills.max_upload_size_mb";
|
||||
|
||||
export function useSkillUploadLimit(open: boolean): number {
|
||||
const http = useHttp();
|
||||
const [limit, setLimit] = useState(DEFAULT_SKILL_UPLOAD_SIZE_MB);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
|
||||
http.get<Record<string, string>>("/v1/system-configs")
|
||||
.then((configs) => {
|
||||
if (!cancelled) {
|
||||
setLimit(normalizeSkillUploadSizeMB(Number(configs[SKILL_UPLOAD_LIMIT_KEY])));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLimit(DEFAULT_SKILL_UPLOAD_SIZE_MB);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, http]);
|
||||
|
||||
return limit;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { validateMultiSkillZip, type MultiSkillZipValidation } from "./validate-skill-zip";
|
||||
import { validateMultiSkillZip, type MultiSkillZipValidation, type SkillZipValidationOptions } from "./validate-skill-zip";
|
||||
import type { SkillEntry, SkillStatus } from "./skill-upload-types";
|
||||
|
||||
type SkillEntrySeed = Omit<SkillEntry, "id">;
|
||||
|
||||
type ValidateSkillArchive = (file: File) => Promise<MultiSkillZipValidation>;
|
||||
type ValidateSkillArchive = (file: File, options?: SkillZipValidationOptions) => Promise<MultiSkillZipValidation>;
|
||||
|
||||
// Browser-side ZIP parsing is best-effort only. Some valid ZIP variants are
|
||||
// accepted by the backend but rejected by JSZip, so fall back to a direct
|
||||
@@ -11,9 +11,10 @@ type ValidateSkillArchive = (file: File) => Promise<MultiSkillZipValidation>;
|
||||
export async function resolveUploadSkills(
|
||||
file: File,
|
||||
validateArchive: ValidateSkillArchive = validateMultiSkillZip,
|
||||
options: SkillZipValidationOptions = {},
|
||||
): Promise<SkillEntrySeed[]> {
|
||||
try {
|
||||
const validation = await validateArchive(file);
|
||||
const validation = await validateArchive(file, options);
|
||||
if (validation.error === "upload.invalidZip") {
|
||||
return [fallbackUploadSkill()];
|
||||
}
|
||||
|
||||
@@ -78,6 +78,20 @@ describe("validateSkillZip (backward compat)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSkillZip upload size limit", () => {
|
||||
it("uses caller-provided limit when validating file size", async () => {
|
||||
const file = new File([new Uint8Array(2 * 1024 * 1024)], "large.zip", { type: "application/zip" });
|
||||
const result = await validateMultiSkillZip(file, { maxUploadSizeMB: 1 });
|
||||
expect(result.error).toBe("upload.tooLarge");
|
||||
});
|
||||
|
||||
it("defaults to 20MB for backward compatibility", async () => {
|
||||
const file = new File([new Uint8Array(2 * 1024 * 1024)], "not-a-real.zip", { type: "application/zip" });
|
||||
const result = await validateSkillZip(file);
|
||||
expect(result.error).toBe("upload.invalidZip");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMultiSkillZip", () => {
|
||||
it("detects multiple skills in ZIP", async () => {
|
||||
const file = await createTestZip({
|
||||
|
||||
@@ -36,8 +36,14 @@ export interface MultiSkillZipValidation {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Constants matching server-side (internal/http/skills.go)
|
||||
const MAX_SKILL_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
export interface SkillZipValidationOptions {
|
||||
maxUploadSizeMB?: number;
|
||||
}
|
||||
|
||||
// Constants matching server-side skill upload limits.
|
||||
export const DEFAULT_SKILL_UPLOAD_SIZE_MB = 20;
|
||||
export const MIN_SKILL_UPLOAD_SIZE_MB = 1;
|
||||
export const MAX_SKILL_UPLOAD_SIZE_MB = 500;
|
||||
const MAX_SKILLS_PER_ZIP = 50;
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
||||
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
@@ -50,8 +56,8 @@ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
* Validate a skill ZIP file client-side — backward-compatible single-skill path.
|
||||
* Delegates to validateMultiSkillZip and returns the first skill's result.
|
||||
*/
|
||||
export async function validateSkillZip(file: File): Promise<SkillZipValidation> {
|
||||
const multi = await validateMultiSkillZip(file);
|
||||
export async function validateSkillZip(file: File, options: SkillZipValidationOptions = {}): Promise<SkillZipValidation> {
|
||||
const multi = await validateMultiSkillZip(file, options);
|
||||
if (multi.error) return { valid: false, error: multi.error };
|
||||
const first = multi.skills[0];
|
||||
if (!first) return { valid: false, error: "upload.noSkillMd" };
|
||||
@@ -75,11 +81,11 @@ export async function validateSkillZip(file: File): Promise<SkillZipValidation>
|
||||
* Returns one SkillValidationEntry per detected SKILL.md, each independently
|
||||
* validated with a SHA-256 contentHash.
|
||||
*/
|
||||
export async function validateMultiSkillZip(file: File): Promise<MultiSkillZipValidation> {
|
||||
export async function validateMultiSkillZip(file: File, options: SkillZipValidationOptions = {}): Promise<MultiSkillZipValidation> {
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
return { skills: [], error: "upload.onlyZip" };
|
||||
}
|
||||
if (file.size > MAX_SKILL_SIZE) {
|
||||
if (file.size > uploadSizeLimitBytes(options.maxUploadSizeMB)) {
|
||||
return { skills: [], error: "upload.tooLarge" };
|
||||
}
|
||||
|
||||
@@ -106,6 +112,17 @@ export async function validateMultiSkillZip(file: File): Promise<MultiSkillZipVa
|
||||
return { skills };
|
||||
}
|
||||
|
||||
export function normalizeSkillUploadSizeMB(value?: number): number {
|
||||
if (!Number.isFinite(value) || value === undefined || value === 0) return DEFAULT_SKILL_UPLOAD_SIZE_MB;
|
||||
if (value < MIN_SKILL_UPLOAD_SIZE_MB) return MIN_SKILL_UPLOAD_SIZE_MB;
|
||||
if (value > MAX_SKILL_UPLOAD_SIZE_MB) return MAX_SKILL_UPLOAD_SIZE_MB;
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function uploadSizeLimitBytes(value?: number): number {
|
||||
return normalizeSkillUploadSizeMB(value) * 1024 * 1024;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,7 @@ import { uniqueId } from "@/lib/utils";
|
||||
import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills";
|
||||
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
|
||||
import { FileEntryBlock } from "./skill-upload-entry";
|
||||
import { useSkillUploadLimit } from "./hooks/use-skill-upload-limit";
|
||||
import JSZip from "jszip";
|
||||
|
||||
interface SkillUploadDialogProps {
|
||||
@@ -38,6 +39,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const [grantManagers, setGrantManagers] = useState(true);
|
||||
const [managerAgentIds, setManagerAgentIds] = useState<string[]>([]);
|
||||
const { agents, refresh: refreshAgents } = useAgents();
|
||||
const maxUploadSizeMB = useSkillUploadLimit(open);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +68,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
// Validate all files concurrently
|
||||
const results = await Promise.all(
|
||||
pending.map(async (entry) => {
|
||||
const resolved = await resolveUploadSkills(entry.file);
|
||||
const resolved = await resolveUploadSkills(entry.file, undefined, { maxUploadSizeMB });
|
||||
return {
|
||||
id: entry.id,
|
||||
skills: resolved.map((skill) => ({
|
||||
@@ -263,7 +265,7 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
<DialogContent className="max-h-[80dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("upload.title")}</DialogTitle>
|
||||
<DialogDescription>{t("upload.description")}</DialogDescription>
|
||||
<DialogDescription>{t("upload.description", { max: maxUploadSizeMB })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Drop zone — hidden once upload starts or finishes */}
|
||||
|
||||
Reference in new issue
Block a user