feat(skills): add slash command activation

This commit is contained in:
Goon
2026-05-24 16:39:44 +07:00
parent 14b32bd219
commit c99f7e1fa4
24 changed files with 978 additions and 40 deletions
+1
View File
@@ -209,6 +209,7 @@ func wireExtras(
ToolPolicy: toolPE,
Skills: skillsLoader,
SkillAccessStore: skillAccessStore,
SkillSlashCommands: appCfg.Skills.SlashCommands,
HasMemory: hasMemory,
TraceCollector: traceCollector,
EnsureUserProfile: ensureUserProfile,
+24
View File
@@ -279,6 +279,30 @@ This decision is re-evaluated each time the system prompt is built, so newly hot
---
## 9.5. Explicit Slash Skill Commands
Users can bypass implicit skill matching by starting a prompt with a slash command:
| Pattern | Behavior |
|---------|----------|
| `/<slug> prompt` | Activates the skill by slug and treats `prompt` as the skill input |
| `/use <slug-or-name> prompt` | Activates the skill by slug or display name |
| `/list-skills` | Shows available skills for the current agent context |
| `/help <slug-or-name>` | Shows description and usage guidance for one skill |
Slash detection runs during prompt construction after request context is scoped and before the skills section is built. A matched skill narrows the per-request `SkillFilter` to that skill and injects the full `SKILL.md` instructions into the system prompt for the current turn only. Normal matching remains unchanged for messages that do not start with the configured prefix, path-like strings such as `/home/user/file`, or unresolved commands without suggestions.
Tenant settings live in `system_configs`:
| Key | Default | Behavior |
|-----|---------|----------|
| `skills.slash_commands.enabled` | `true` | Enable slash command detection |
| `skills.slash_commands.suggest_not_found` | `true` | Suggest similar skills for unknown commands |
| `skills.slash_commands.partial_matching` | `false` | Allow unique prefixes such as `/frontend` |
| `skills.slash_commands.prefix` | `/` | Single-character command prefix |
---
## 10. Skills -- BM25 Search
An in-memory BM25 index provides keyword-based skill search. The index is lazily rebuilt whenever the skill version changes.
+5
View File
@@ -29,6 +29,11 @@ How skills access Python, Node.js, and system tools inside Docker containers and
└─────────────────────────────────────────────────────────┘
```
Explicit skill activation is handled before runtime execution. When a user starts
their prompt with `/<skill-slug>` or `/use <skill name>`, the gateway resolves
the skill, injects its `SKILL.md` into the current turn, and then normal runtime
rules apply to any scripts or package dependencies that skill uses.
---
## 2. Pre-installed Packages (Option A)
+6
View File
@@ -337,6 +337,12 @@ tenant `system_configs["skills.max_upload_size_mb"]`, then `SKILL.md` frontmatte
`GOCLAW_SKILLS_MAX_UPLOAD_SIZE_MB`, then the default 20 MB. Values are clamped
to 1-500 MB.
Skill slash-command behavior is configured through tenant `system_configs`:
`skills.slash_commands.enabled`, `skills.slash_commands.suggest_not_found`,
`skills.slash_commands.partial_matching`, and `skills.slash_commands.prefix`.
The default prefix is `/`; supported prompt forms are `/<slug> prompt`,
`/use <slug-or-name> prompt`, `/list-skills`, and `/help <slug-or-name>`.
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent |
+11
View File
@@ -6,6 +6,17 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-05-24
### Slash skill commands
**Features**
- Added explicit slash skill activation for `/<slug>`, `/use <slug-or-name>`, `/list-skills`, and `/help <slug-or-name>`.
- Added tenant settings for slash command enablement, similar-skill suggestions, partial matching, and custom prefix.
**Tests**
- Added backend coverage for parser false positives, exact/partial skill resolution, suggestions, help/list commands, and config overlays.
### Configurable skill upload limits
**Features**
+2
View File
@@ -117,6 +117,8 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
}
}
userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, userMessage, extraSystemPrompt, skillFilter)
// Build tool list, filtering out skill_manage when skill_evolve is off.
// Also applies ChannelAware filtering so channel-specific tools don't
// appear in ## Tooling when the current channel doesn't support them.
+16 -10
View File
@@ -122,11 +122,12 @@ type Loop struct {
summarizeMu sync.Map // sessionKey → *sync.Mutex
// Bootstrap/persona context (loaded at startup, injected into system prompt)
ownerIDs []string
skillsLoader *skills.Loader
skillAllowList []string // nil = all, [] = none, ["x","y"] = filter
hasMemory bool
contextFiles []bootstrap.ContextFile
ownerIDs []string
skillsLoader *skills.Loader
skillAllowList []string // nil = all, [] = none, ["x","y"] = filter
skillSlashCommands config.SkillSlashCommandConfig
hasMemory bool
contextFiles []bootstrap.ContextFile
// Per-user profile + file seeding + dynamic context loading
ensureUserProfile EnsureUserProfileFunc // create/resolve user profile + workspace
@@ -185,6 +186,7 @@ type Loop struct {
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
// Filesystem tools (read_file, write_file, edit, list_files) check these at execution time.
tenantAllowedPaths []string
systemConfigs store.SystemConfigStore
// Per-tenant disabled tools (tool name → true means excluded from LLM)
disabledTools map[string]bool
@@ -330,11 +332,12 @@ type LoopConfig struct {
OnEvent func(AgentEvent)
// Bootstrap/persona context
OwnerIDs []string
SkillsLoader *skills.Loader
SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter
HasMemory bool
ContextFiles []bootstrap.ContextFile
OwnerIDs []string
SkillsLoader *skills.Loader
SkillAllowList []string // nil = all, [] = none, ["x","y"] = filter
SkillSlashCommands config.SkillSlashCommandConfig
HasMemory bool
ContextFiles []bootstrap.ContextFile
// Compaction config
CompactionCfg *config.CompactionConfig
@@ -383,6 +386,7 @@ type LoopConfig struct {
// Tenant-specific allowed paths beyond workspace (from system_configs['allowed_paths']).
TenantAllowedPaths []string
SystemConfigs store.SystemConfigStore
// Per-tenant disabled tools (tool name → true means excluded)
DisabledTools map[string]bool
@@ -530,6 +534,7 @@ func NewLoop(cfg LoopConfig) *Loop {
ownerIDs: cfg.OwnerIDs,
skillsLoader: cfg.SkillsLoader,
skillAllowList: cfg.SkillAllowList,
skillSlashCommands: cfg.SkillSlashCommands,
hasMemory: cfg.HasMemory,
contextFiles: cfg.ContextFiles,
defaultTimezone: cfg.DefaultTimezone,
@@ -553,6 +558,7 @@ func NewLoop(cfg LoopConfig) *Loop {
builtinToolSettings: cfg.BuiltinToolSettings,
tenantToolSettings: cfg.TenantToolSettings,
tenantAllowedPaths: cfg.TenantAllowedPaths,
systemConfigs: cfg.SystemConfigs,
disabledTools: cfg.DisabledTools,
reasoningConfig: cfg.ReasoningConfig,
promptMode: cfg.PromptMode,
+4 -1
View File
@@ -85,7 +85,8 @@ type ResolverDeps struct {
MCPGrantChecker mcpbridge.GrantChecker
// Skill access store — for per-agent skill visibility filtering
SkillAccessStore store.SkillAccessStore
SkillAccessStore store.SkillAccessStore
SkillSlashCommands config.SkillSlashCommandConfig
// Config permission store for group file writer checks
ConfigPermStore store.ConfigPermissionStore
@@ -491,6 +492,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
AgentToolPolicy: agentToolPolicyForTeam(agentToolPolicyWithWorkspace(agentToolPolicyWithMCP(ag.ParseToolsConfig(), hasMCPTools), hasTeam), isTeamLead),
SkillsLoader: deps.Skills,
SkillAllowList: skillAllowList,
SkillSlashCommands: deps.SkillSlashCommands,
HasMemory: hasMemory,
ContextFiles: contextFiles,
EnsureUserProfile: deps.EnsureUserProfile,
@@ -511,6 +513,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
BuiltinToolSettings: builtinSettings,
TenantToolSettings: tenantToolSettings,
TenantAllowedPaths: tenantAllowedPaths,
SystemConfigs: deps.SystemConfigs,
DisabledTools: disabledTools,
ReasoningConfig: store.ResolveEffectiveReasoningConfig(providerReasoningDefaults, ag.ParseReasoningConfig()),
PromptMode: PromptMode(ag.ParsePromptMode()),
@@ -0,0 +1,65 @@
package agent
import (
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
func (r skillSlashCommandResult) systemPromptSection() string {
switch r.Kind {
case skillSlashCommandActivate:
return fmt.Sprintf("## Explicit Skill Activation\n\nThe user explicitly requested skill `%s` (%s). Use this skill for the current request and treat the remaining user message as the skill input. Call `use_skill` with name `%s` for observability when available, then follow these instructions:\n\n%s", r.Skill.Slug, r.Skill.Name, r.Skill.Slug, r.SkillContent)
case skillSlashCommandList, skillSlashCommandHelp, skillSlashCommandUnknown:
return r.Guidance
default:
return ""
}
}
func buildSkillSlashListGuidance(all []skills.Info) string {
if len(all) == 0 {
return "## Skill Slash Command\n\nNo skills are currently available."
}
sort.Slice(all, func(i, j int) bool { return all[i].Slug < all[j].Slug })
var lines []string
lines = append(lines, "## Skill Slash Command", "", "Available skills:")
for _, skill := range all {
lines = append(lines, fmt.Sprintf("- `%s` - %s", skill.Slug, skillDisplayDescription(skill)))
}
return strings.Join(lines, "\n")
}
func buildSkillSlashHelpGuidance(skill skills.Info) string {
return fmt.Sprintf("## Skill Slash Command\n\nSkill `%s` (%s)\nDescription: %s\nLocation: %s\nExplain this skill and how to invoke it with the configured slash prefix.", skill.Slug, skill.Name, skillDisplayDescription(skill), filepath.ToSlash(skill.Path))
}
func buildSkillSlashUnknownGuidance(target string, suggestions []skills.Info) string {
var lines []string
lines = append(lines, "## Skill Slash Command", "", fmt.Sprintf("Requested skill `%s` was not found. Suggest these available alternatives:", target))
for _, skill := range suggestions {
lines = append(lines, fmt.Sprintf("- `%s` - %s", skill.Slug, skillDisplayDescription(skill)))
}
return strings.Join(lines, "\n")
}
func skillDisplayDescription(skill skills.Info) string {
if strings.TrimSpace(skill.Description) != "" {
return strings.TrimSpace(skill.Description)
}
return skill.Name
}
func appendExtraPrompt(existing, addition string) string {
addition = strings.TrimSpace(addition)
if addition == "" {
return existing
}
if strings.TrimSpace(existing) == "" {
return addition
}
return existing + "\n\n" + addition
}
@@ -0,0 +1,124 @@
package agent
import (
"sort"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
type parsedSkillSlashCommand struct {
verb string
target string
rest string
}
func parseSkillSlashCommand(message, prefix string) (parsedSkillSlashCommand, bool) {
message = strings.TrimSpace(message)
if message == "" || !strings.HasPrefix(message, prefix) {
return parsedSkillSlashCommand{}, false
}
after := strings.TrimSpace(strings.TrimPrefix(message, prefix))
if after == "" || looksLikePath(after) {
return parsedSkillSlashCommand{}, false
}
first, rest, _ := strings.Cut(after, " ")
first = strings.TrimSpace(first)
rest = strings.TrimSpace(rest)
switch strings.ToLower(first) {
case "list-skills":
return parsedSkillSlashCommand{verb: "list-skills"}, true
case "help":
if rest == "" {
return parsedSkillSlashCommand{}, false
}
return parsedSkillSlashCommand{verb: "help", target: rest}, true
case "use", "activate":
if rest == "" {
return parsedSkillSlashCommand{}, false
}
return parsedSkillSlashCommand{verb: strings.ToLower(first), rest: rest}, true
default:
return parsedSkillSlashCommand{verb: "direct", target: first, rest: rest}, true
}
}
func looksLikePath(value string) bool {
first, _, _ := strings.Cut(value, " ")
return strings.Contains(first, "/") || strings.Contains(first, "\\") || strings.Contains(first, ".")
}
func firstWord(value string) (string, string) {
first, rest, ok := strings.Cut(strings.TrimSpace(value), " ")
if !ok {
return strings.TrimSpace(value), ""
}
return strings.TrimSpace(first), strings.TrimSpace(rest)
}
func matchSkillCommandTarget(all []skills.Info, raw string, partial bool) (skills.Info, bool, string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return skills.Info{}, false, ""
}
type candidate struct {
info skills.Info
matchText string
remainder string
score int
}
var matches []candidate
lowerRaw := strings.ToLower(raw)
partialTarget, partialRemainder := firstWord(raw)
lowerPartialTarget := strings.ToLower(partialTarget)
for _, skill := range all {
for _, value := range []string{skill.Slug, skill.Name} {
value = strings.TrimSpace(value)
if value == "" {
continue
}
lowerValue := strings.ToLower(value)
if lowerRaw == lowerValue {
matches = append(matches, candidate{info: skill, matchText: value, score: len([]rune(value))})
continue
}
if strings.HasPrefix(lowerRaw, lowerValue+" ") {
remainder := trimMatchedSkillCommandPrefix(raw, value)
matches = append(matches, candidate{info: skill, matchText: value, remainder: remainder, score: len([]rune(value))})
continue
}
if partial && lowerPartialTarget != "" && strings.HasPrefix(lowerValue, lowerPartialTarget) {
matches = append(matches, candidate{info: skill, matchText: value, remainder: partialRemainder, score: len([]rune(partialTarget))})
}
}
}
if len(matches) == 0 {
return skills.Info{}, false, ""
}
sort.Slice(matches, func(i, j int) bool {
return matches[i].score > matches[j].score
})
bestBySlug := make([]candidate, 0, len(matches))
seenSlug := make(map[string]struct{}, len(matches))
for _, match := range matches {
if _, ok := seenSlug[match.info.Slug]; ok {
continue
}
seenSlug[match.info.Slug] = struct{}{}
bestBySlug = append(bestBySlug, match)
}
best := bestBySlug[0]
if len(bestBySlug) > 1 && bestBySlug[0].score == bestBySlug[1].score {
return skills.Info{}, false, ""
}
return best.info, true, best.remainder
}
func trimMatchedSkillCommandPrefix(raw, matched string) string {
rawRunes := []rune(raw)
matchedRunes := []rune(matched)
if len(rawRunes) < len(matchedRunes) {
return ""
}
return strings.TrimSpace(string(rawRunes[len(matchedRunes):]))
}
@@ -0,0 +1,102 @@
package agent
import (
"sort"
"strings"
"unicode"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
func unknownSkillSlashResult(all []skills.Info, target string, cfg config.SkillSlashCommandConfig) skillSlashCommandResult {
if !cfg.EffectiveSuggestNotFound() {
return skillSlashCommandResult{Kind: skillSlashCommandNone}
}
suggestions := similarSkills(all, target, 3)
if len(suggestions) == 0 {
return skillSlashCommandResult{Kind: skillSlashCommandNone}
}
return skillSlashCommandResult{
Kind: skillSlashCommandUnknown,
Guidance: buildSkillSlashUnknownGuidance(target, suggestions),
Suggestions: suggestions,
}
}
func similarSkills(all []skills.Info, target string, limit int) []skills.Info {
target = strings.ToLower(strings.TrimSpace(target))
if target == "" {
return nil
}
type scored struct {
info skills.Info
score int
}
var scoredSkills []scored
for _, skill := range all {
best := scoreSimilarSkill(target, skill)
if best <= 3 {
scoredSkills = append(scoredSkills, scored{info: skill, score: best})
}
}
sort.Slice(scoredSkills, func(i, j int) bool {
if scoredSkills[i].score == scoredSkills[j].score {
return scoredSkills[i].info.Slug < scoredSkills[j].info.Slug
}
return scoredSkills[i].score < scoredSkills[j].score
})
if len(scoredSkills) > limit {
scoredSkills = scoredSkills[:limit]
}
out := make([]skills.Info, len(scoredSkills))
for i, scored := range scoredSkills {
out[i] = scored.info
}
return out
}
func scoreSimilarSkill(target string, skill skills.Info) int {
targetRunes := []rune(target)
slug := strings.ToLower(skill.Slug)
best := slashSkillDistance(target, slug)
if slugRunes := []rune(slug); len(slugRunes) >= len(targetRunes) {
best = min(best, slashSkillDistance(target, string(slugRunes[:len(targetRunes)])))
}
name := strings.ToLower(skill.Name)
if name != "" {
best = min(best, slashSkillDistance(target, name))
if nameRunes := []rune(name); len(nameRunes) >= len(targetRunes) {
best = min(best, slashSkillDistance(target, string(nameRunes[:len(targetRunes)])))
}
}
if strings.HasPrefix(slug, target) || strings.Contains(name, target) {
best = 0
}
return best
}
func slashSkillDistance(a, b string) int {
ar := []rune(a)
br := []rune(b)
if len(ar) == 0 {
return len(br)
}
prev := make([]int, len(br)+1)
for j := range prev {
prev[j] = j
}
for i, ca := range ar {
cur := make([]int, len(br)+1)
cur[0] = i + 1
for j, cb := range br {
cost := 0
if unicode.ToLower(ca) != unicode.ToLower(cb) {
cost = 1
}
cur[j+1] = min(min(cur[j]+1, prev[j+1]+1), prev[j]+cost)
}
prev = cur
}
return prev[len(br)]
}
+130
View File
@@ -0,0 +1,130 @@
package agent
import (
"context"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
type skillSlashCommandKind int
const (
skillSlashCommandNone skillSlashCommandKind = iota
skillSlashCommandActivate
skillSlashCommandList
skillSlashCommandHelp
skillSlashCommandUnknown
)
type skillSlashCommandResult struct {
Kind skillSlashCommandKind
Skill skills.Info
SkillContent string
RemainingPrompt string
Guidance string
Suggestions []skills.Info
}
func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt string, skillFilter []string) (string, string, []string) {
result := resolveSkillSlashCommand(ctx, l.skillsLoader, l.resolveSkillSlashCommandConfig(ctx), message)
if result.Kind == skillSlashCommandNone {
return message, extraPrompt, skillFilter
}
extraPrompt = appendExtraPrompt(extraPrompt, result.systemPromptSection())
switch result.Kind {
case skillSlashCommandActivate:
if result.RemainingPrompt == "" {
message = "Use the activated skill to help with the user's request."
} else {
message = result.RemainingPrompt
}
skillFilter = []string{result.Skill.Slug}
case skillSlashCommandList:
message = "List the available skills shown in the system instructions."
case skillSlashCommandHelp:
message = "Explain the requested skill and how it should be used."
case skillSlashCommandUnknown:
message = "Explain that the requested skill was not found and suggest available alternatives."
}
return message, extraPrompt, skillFilter
}
func (l *Loop) resolveSkillSlashCommandConfig(ctx context.Context) config.SkillSlashCommandConfig {
cfg := l.skillSlashCommands
if l.systemConfigs == nil {
return cfg
}
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashCommandsEnabledSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
v := parseSkillSlashBool(raw)
cfg.Enabled = &v
}
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashSuggestNotFoundSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
v := parseSkillSlashBool(raw)
cfg.SuggestNotFound = &v
}
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashPartialMatchingSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
cfg.PartialMatching = parseSkillSlashBool(raw)
}
if raw, err := l.systemConfigs.Get(ctx, config.SkillSlashCommandPrefixSystemConfigKey); err == nil && strings.TrimSpace(raw) != "" {
cfg.Prefix = raw
}
return cfg
}
func resolveSkillSlashCommand(ctx context.Context, loader *skills.Loader, cfg config.SkillSlashCommandConfig, message string) skillSlashCommandResult {
if loader == nil || !cfg.EffectiveEnabled() {
return skillSlashCommandResult{Kind: skillSlashCommandNone}
}
parsed, ok := parseSkillSlashCommand(message, cfg.EffectivePrefix())
if !ok {
return skillSlashCommandResult{Kind: skillSlashCommandNone}
}
all := loader.ListSkills(ctx)
switch parsed.verb {
case "list-skills":
return skillSlashCommandResult{Kind: skillSlashCommandList, Guidance: buildSkillSlashListGuidance(all)}
case "help":
skill, matched, _ := matchSkillCommandTarget(all, parsed.target, cfg.EffectivePartialMatching())
if !matched {
return unknownSkillSlashResult(all, parsed.target, cfg)
}
return skillSlashCommandResult{Kind: skillSlashCommandHelp, Skill: skill, Guidance: buildSkillSlashHelpGuidance(skill)}
case "use", "activate":
return resolveSkillActivation(ctx, loader, all, parsed.rest, cfg)
default:
return resolveSkillActivation(ctx, loader, all, parsed.target+" "+parsed.rest, cfg)
}
}
func resolveSkillActivation(ctx context.Context, loader *skills.Loader, all []skills.Info, raw string, cfg config.SkillSlashCommandConfig) skillSlashCommandResult {
skill, matched, remainder := matchSkillCommandTarget(all, raw, cfg.EffectivePartialMatching())
if !matched {
fields := strings.Fields(raw)
target := strings.TrimSpace(raw)
if len(fields) > 0 {
target = fields[0]
}
return unknownSkillSlashResult(all, target, cfg)
}
content, ok := loader.LoadSkill(ctx, skill.Slug)
if !ok {
return unknownSkillSlashResult(all, skill.Slug, cfg)
}
return skillSlashCommandResult{
Kind: skillSlashCommandActivate,
Skill: skill,
SkillContent: content,
RemainingPrompt: strings.TrimSpace(remainder),
}
}
func parseSkillSlashBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+138
View File
@@ -0,0 +1,138 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/skills"
)
func TestResolveSkillSlashCommandExactSlug(t *testing.T) {
loader := newSlashTestLoader(t)
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/frontend-design build a landing page")
if result.Kind != skillSlashCommandActivate {
t.Fatalf("kind = %v, want activate", result.Kind)
}
if result.Skill.Slug != "frontend-design" {
t.Fatalf("slug = %q, want frontend-design", result.Skill.Slug)
}
if result.RemainingPrompt != "build a landing page" {
t.Fatalf("remaining = %q", result.RemainingPrompt)
}
if !strings.Contains(result.SkillContent, "Use responsive components.") {
t.Fatal("expected loaded SKILL.md content")
}
}
func TestResolveSkillSlashCommandExactNameUseSyntax(t *testing.T) {
loader := newSlashTestLoader(t)
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/use Frontend Design build a landing page")
if result.Kind != skillSlashCommandActivate {
t.Fatalf("kind = %v, want activate", result.Kind)
}
if result.Skill.Slug != "frontend-design" {
t.Fatalf("slug = %q, want frontend-design", result.Skill.Slug)
}
if result.RemainingPrompt != "build a landing page" {
t.Fatalf("remaining = %q", result.RemainingPrompt)
}
}
func TestResolveSkillSlashCommandPartialMatchRequiresUniqueEnabled(t *testing.T) {
loader := newSlashTestLoader(t)
disabled := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, "/front build")
if disabled.Kind != skillSlashCommandUnknown {
t.Fatalf("disabled partial kind = %v, want unknown", disabled.Kind)
}
enabled := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/", PartialMatching: true}, "/front build")
if enabled.Kind != skillSlashCommandActivate {
t.Fatalf("enabled partial kind = %v, want activate", enabled.Kind)
}
if enabled.Skill.Slug != "frontend-design" {
t.Fatalf("slug = %q, want frontend-design", enabled.Skill.Slug)
}
}
func TestResolveSkillSlashCommandFalsePositives(t *testing.T) {
loader := newSlashTestLoader(t)
for _, msg := range []string{"/home/user/project", "/etc/config.yaml", "https://example.com/path", "regular prompt"} {
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}, msg)
if result.Kind != skillSlashCommandNone {
t.Fatalf("%q kind = %v, want none", msg, result.Kind)
}
}
}
func TestResolveSkillSlashCommandListAndHelp(t *testing.T) {
loader := newSlashTestLoader(t)
cfg := config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/"}
list := resolveSkillSlashCommand(context.Background(), loader, cfg, "/list-skills")
if list.Kind != skillSlashCommandList {
t.Fatalf("list kind = %v, want list", list.Kind)
}
if !strings.Contains(list.Guidance, "frontend-design") || !strings.Contains(list.Guidance, "git-helper") {
t.Fatalf("list guidance missing skills: %s", list.Guidance)
}
help := resolveSkillSlashCommand(context.Background(), loader, cfg, "/help frontend-design")
if help.Kind != skillSlashCommandHelp {
t.Fatalf("help kind = %v, want help", help.Kind)
}
if help.Skill.Slug != "frontend-design" || !strings.Contains(help.Guidance, "Frontend Design") {
t.Fatalf("unexpected help result: %#v", help)
}
helpByName := resolveSkillSlashCommand(context.Background(), loader, cfg, "/help Frontend Design")
if helpByName.Kind != skillSlashCommandHelp {
t.Fatalf("help by name kind = %v, want help", helpByName.Kind)
}
if helpByName.Skill.Slug != "frontend-design" {
t.Fatalf("help by name slug = %q, want frontend-design", helpByName.Skill.Slug)
}
}
func TestResolveSkillSlashCommandSuggestsUnknown(t *testing.T) {
loader := newSlashTestLoader(t)
result := resolveSkillSlashCommand(context.Background(), loader, config.SkillSlashCommandConfig{Enabled: boolPtr(true), Prefix: "/", SuggestNotFound: boolPtr(true)}, "/fronted build")
if result.Kind != skillSlashCommandUnknown {
t.Fatalf("kind = %v, want unknown", result.Kind)
}
if len(result.Suggestions) == 0 || result.Suggestions[0].Slug != "frontend-design" {
t.Fatalf("suggestions = %#v", result.Suggestions)
}
}
func boolPtr(v bool) *bool {
return &v
}
func newSlashTestLoader(t *testing.T) *skills.Loader {
t.Helper()
t.Setenv("GOCLAW_DISABLE_PERSONAL_SKILLS", "1")
root := t.TempDir()
writeSkill(t, root, "frontend-design", "Frontend Design", "Create polished UI layouts.", "Use responsive components.")
writeSkill(t, root, "git-helper", "Git Helper", "Handle git workflows.", "Use clean commits.")
return skills.NewLoader("", root, "")
}
func writeSkill(t *testing.T, root, slug, name, description, body string) {
t.Helper()
dir := filepath.Join(root, slug)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n" + body + "\n"
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
+49 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -130,8 +131,17 @@ 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/)
MaxUploadSizeMB int `json:"max_upload_size_mb,omitempty"` // per-file ZIP upload limit
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
SlashCommands SkillSlashCommandConfig `json:"slash_commands,omitempty"`
}
// SkillSlashCommandConfig controls explicit slash-command skill activation.
type SkillSlashCommandConfig struct {
Enabled *bool `json:"enabled,omitempty"`
SuggestNotFound *bool `json:"suggest_not_found,omitempty"`
PartialMatching bool `json:"partial_matching,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
const (
@@ -139,7 +149,13 @@ const (
MinSkillMaxUploadSizeMB = 1
MaxSkillMaxUploadSizeMB = 500
SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb"
DefaultSkillSlashCommandPrefix = "/"
SkillMaxUploadSizeSystemConfigKey = "skills.max_upload_size_mb"
SkillSlashCommandsEnabledSystemConfigKey = "skills.slash_commands.enabled"
SkillSlashSuggestNotFoundSystemConfigKey = "skills.slash_commands.suggest_not_found"
SkillSlashPartialMatchingSystemConfigKey = "skills.slash_commands.partial_matching"
SkillSlashCommandPrefixSystemConfigKey = "skills.slash_commands.prefix"
)
func ClampSkillMaxUploadSizeMB(value int) int {
@@ -163,6 +179,36 @@ func (c SkillsConfig) EffectiveMaxUploadSizeBytes() int64 {
return int64(c.EffectiveMaxUploadSizeMB()) << 20
}
func (c SkillSlashCommandConfig) EffectiveEnabled() bool {
if c.Enabled == nil {
return true
}
return *c.Enabled
}
func (c SkillSlashCommandConfig) EffectiveSuggestNotFound() bool {
if c.SuggestNotFound == nil {
return true
}
return *c.SuggestNotFound
}
func (c SkillSlashCommandConfig) EffectivePartialMatching() bool {
return c.PartialMatching
}
func (c SkillSlashCommandConfig) EffectivePrefix() string {
prefix := strings.TrimSpace(c.Prefix)
if prefix == "" {
return DefaultSkillSlashCommandPrefix
}
runes := []rune(prefix)
if len(runes) != 1 || runes[0] == ' ' || runes[0] == '\t' || runes[0] == '\n' || runes[0] == '\r' {
return DefaultSkillSlashCommandPrefix
}
return prefix
}
// AgentBinding maps a channel/peer pattern to a specific agent.
// Matching TS AgentBinding from config/types.agents.ts.
type AgentBinding struct {
+24
View File
@@ -63,6 +63,15 @@ func isLoopbackGatewayHost(host string) bool {
return err == nil && addr.IsLoopback()
}
func parseEnvBool(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
// Default returns a Config with sensible defaults.
func Default() *Config {
return &Config{
@@ -242,6 +251,21 @@ func (c *Config) applyEnvOverrides() {
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(mb)
}
}
envBoolPtr := func(key string, dst **bool) {
if v := os.Getenv(key); v != "" {
b := parseEnvBool(v)
*dst = &b
}
}
envBool := func(key string, dst *bool) {
if v := os.Getenv(key); v != "" {
*dst = parseEnvBool(v)
}
}
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_ENABLED", &c.Skills.SlashCommands.Enabled)
envBoolPtr("GOCLAW_SKILLS_SLASH_COMMANDS_SUGGEST_NOT_FOUND", &c.Skills.SlashCommands.SuggestNotFound)
envBool("GOCLAW_SKILLS_SLASH_COMMANDS_PARTIAL_MATCHING", &c.Skills.SlashCommands.PartialMatching)
envStr("GOCLAW_SKILLS_SLASH_COMMANDS_PREFIX", &c.Skills.SlashCommands.Prefix)
// Database
envStr("GOCLAW_POSTGRES_DSN", &c.Database.PostgresDSN)
+84
View File
@@ -27,6 +27,18 @@ func TestDefault_SensibleDefaults(t *testing.T) {
if cfg.Skills.EffectiveMaxUploadSizeMB() != DefaultSkillMaxUploadSizeMB {
t.Fatalf("default skill upload max: got %d, want %d", cfg.Skills.EffectiveMaxUploadSizeMB(), DefaultSkillMaxUploadSizeMB)
}
if !cfg.Skills.SlashCommands.EffectiveEnabled() {
t.Fatal("slash commands should default enabled")
}
if !cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
t.Fatal("slash command suggestions should default enabled")
}
if cfg.Skills.SlashCommands.EffectivePartialMatching() {
t.Fatal("slash command partial matching should default disabled")
}
if cfg.Skills.SlashCommands.EffectivePrefix() != "/" {
t.Fatalf("slash command prefix = %q, want /", cfg.Skills.SlashCommands.EffectivePrefix())
}
}
@@ -129,6 +141,78 @@ func TestLoad_SkillsMaxUploadSizeFromFileAndEnv(t *testing.T) {
}
}
func TestLoad_SkillSlashCommandsFromFileEnvAndSystemConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.json5")
os.WriteFile(cfgPath, []byte(`{
"skills": {
"slash_commands": {
"enabled": false,
"suggest_not_found": false,
"partial_matching": true,
"prefix": "!"
}
}
}`), 0644)
cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("load error: %v", err)
}
if cfg.Skills.SlashCommands.EffectiveEnabled() {
t.Fatal("file enabled override should be false")
}
if cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
t.Fatal("file suggestion override should be false")
}
if !cfg.Skills.SlashCommands.EffectivePartialMatching() {
t.Fatal("file partial matching override should be true")
}
if cfg.Skills.SlashCommands.EffectivePrefix() != "!" {
t.Fatalf("file prefix = %q, want !", cfg.Skills.SlashCommands.EffectivePrefix())
}
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_ENABLED", "true")
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_SUGGEST_NOT_FOUND", "true")
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_PARTIAL_MATCHING", "false")
t.Setenv("GOCLAW_SKILLS_SLASH_COMMANDS_PREFIX", "#")
cfg, err = Load(cfgPath)
if err != nil {
t.Fatalf("load with env error: %v", err)
}
if !cfg.Skills.SlashCommands.EffectiveEnabled() {
t.Fatal("env enabled override should be true")
}
if !cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
t.Fatal("env suggestion override should be true")
}
if cfg.Skills.SlashCommands.EffectivePartialMatching() {
t.Fatal("env partial matching override should be false")
}
if cfg.Skills.SlashCommands.EffectivePrefix() != "#" {
t.Fatalf("env prefix = %q, want #", cfg.Skills.SlashCommands.EffectivePrefix())
}
cfg.ApplySystemConfigs(map[string]string{
"skills.slash_commands.enabled": "false",
"skills.slash_commands.suggest_not_found": "false",
"skills.slash_commands.partial_matching": "true",
"skills.slash_commands.prefix": "%",
})
if cfg.Skills.SlashCommands.EffectiveEnabled() {
t.Fatal("system enabled override should be false")
}
if cfg.Skills.SlashCommands.EffectiveSuggestNotFound() {
t.Fatal("system suggestion override should be false")
}
if !cfg.Skills.SlashCommands.EffectivePartialMatching() {
t.Fatal("system partial matching override should be true")
}
if cfg.Skills.SlashCommands.EffectivePrefix() != "%" {
t.Fatalf("system prefix = %q, want %%", cfg.Skills.SlashCommands.EffectivePrefix())
}
}
func TestSkillsMaxUploadSizeClampAndSystemConfigOverlay(t *testing.T) {
cfg := Default()
cfg.Skills.MaxUploadSizeMB = 0
+4
View File
@@ -77,6 +77,10 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
// Skills
integer(SkillMaxUploadSizeSystemConfigKey, &c.Skills.MaxUploadSizeMB)
c.Skills.MaxUploadSizeMB = ClampSkillMaxUploadSizeMB(c.Skills.MaxUploadSizeMB)
boolean(SkillSlashCommandsEnabledSystemConfigKey, &c.Skills.SlashCommands.Enabled)
boolean(SkillSlashSuggestNotFoundSystemConfigKey, &c.Skills.SlashCommands.SuggestNotFound)
boolValue(SkillSlashPartialMatchingSystemConfigKey, &c.Skills.SlashCommands.PartialMatching)
str(SkillSlashCommandPrefixSystemConfigKey, &c.Skills.SlashCommands.Prefix)
// TTS
str("tts.provider", &c.Tts.Provider)
+45 -19
View File
@@ -22,7 +22,7 @@ type ConfigMethods struct {
cfgPath string
secretsStore store.ConfigSecretsStore
syncFn func(ctx context.Context, cfg *config.Config) // nil-safe; syncs non-secret settings to system_configs
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
eventBus bus.EventPublisher // nil-safe; broadcasts config change events
}
func NewConfigMethods(cfg *config.Config, cfgPath string, secretsStore store.ConfigSecretsStore, eventBus bus.EventPublisher) *ConfigMethods {
@@ -265,27 +265,53 @@ 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)",
},
"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",
"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",
},
"slash_commands": map[string]any{
"type": "object",
"description": "Explicit slash command skill activation settings",
"properties": map[string]any{
"enabled": map[string]any{
"type": "boolean",
"default": true,
"description": "Enable slash command detection in user prompts",
},
"suggest_not_found": map[string]any{
"type": "boolean",
"default": true,
"description": "Suggest similar skills when a requested skill is not found",
},
"partial_matching": map[string]any{
"type": "boolean",
"default": false,
"description": "Allow unique skill slug/name prefixes",
},
"prefix": map[string]any{
"type": "string",
"default": config.DefaultSkillSlashCommandPrefix,
"description": "Single-character slash command prefix",
},
},
},
},
"sessions": map[string]any{
"type": "object",
"description": "Session storage configuration",
},
},
"sessions": map[string]any{
"type": "object",
"description": "Session storage configuration",
},
},
}
@@ -45,6 +45,10 @@ export interface InitState {
bgProvider: string;
bgModel: string;
skillUploadMaxSize: string;
skillSlashEnabled: boolean;
skillSlashSuggest: boolean;
skillSlashPartial: boolean;
skillSlashPrefix: string;
}
export const DEFAULTS: InitState = {
@@ -56,6 +60,10 @@ export const DEFAULTS: InitState = {
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
bgProvider: "", bgModel: "",
skillUploadMaxSize: "20",
skillSlashEnabled: true,
skillSlashSuggest: true,
skillSlashPartial: false,
skillSlashPrefix: "/",
};
export function parseBool(v: string | undefined, fallback: boolean): boolean {
@@ -62,6 +62,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
const [bgProvider, setBgProvider] = useState("");
const [bgModel, setBgModel] = useState("");
const [skillUploadMaxSize, setSkillUploadMaxSize] = useState("20");
const [skillSlashEnabled, setSkillSlashEnabled] = useState(true);
const [skillSlashSuggest, setSkillSlashSuggest] = useState(true);
const [skillSlashPartial, setSkillSlashPartial] = useState(false);
const [skillSlashPrefix, setSkillSlashPrefix] = useState("/");
const applyConfigs = useCallback((
configs: Record<string, string>,
@@ -79,6 +83,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
skillUploadMaxSize: configs["skills.max_upload_size_mb"] ?? "20",
skillSlashEnabled: parseBool(configs["skills.slash_commands.enabled"], true),
skillSlashSuggest: parseBool(configs["skills.slash_commands.suggest_not_found"], true),
skillSlashPartial: parseBool(configs["skills.slash_commands.partial_matching"], false),
skillSlashPrefix: configs["skills.slash_commands.prefix"] ?? "/",
};
setInit(s);
setEmbProvider(s.embProvider); setEmbModel(s.embModel); setEmbMaxChunkLen(s.embMaxChunkLen); setEmbChunkOverlap(s.embChunkOverlap);
@@ -87,6 +95,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
setSkillUploadMaxSize(s.skillUploadMaxSize);
setSkillSlashEnabled(s.skillSlashEnabled);
setSkillSlashSuggest(s.skillSlashSuggest);
setSkillSlashPartial(s.skillSlashPartial);
setSkillSlashPrefix(s.skillSlashPrefix);
resetEmb();
}, [resetEmb]);
@@ -131,6 +143,10 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
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;
if (skillSlashEnabled !== init.skillSlashEnabled) updates["skills.slash_commands.enabled"] = String(skillSlashEnabled);
if (skillSlashSuggest !== init.skillSlashSuggest) updates["skills.slash_commands.suggest_not_found"] = String(skillSlashSuggest);
if (skillSlashPartial !== init.skillSlashPartial) updates["skills.slash_commands.partial_matching"] = String(skillSlashPartial);
if (skillSlashPrefix !== init.skillSlashPrefix) updates["skills.slash_commands.prefix"] = skillSlashPrefix.trim() || "/";
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) {
@@ -211,6 +227,14 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
<SystemSettingsSkillsCard
uploadMaxSize={skillUploadMaxSize}
setUploadMaxSize={setSkillUploadMaxSize}
slashEnabled={skillSlashEnabled}
setSlashEnabled={setSkillSlashEnabled}
slashSuggest={skillSlashSuggest}
setSlashSuggest={setSkillSlashSuggest}
slashPartial={skillSlashPartial}
setSlashPartial={setSkillSlashPartial}
slashPrefix={skillSlashPrefix}
setSlashPrefix={setSkillSlashPrefix}
/>
<SystemSettingsCompactionCard
@@ -3,15 +3,32 @@ 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";
import { Switch } from "@/components/ui/switch";
interface SystemSettingsSkillsCardProps {
uploadMaxSize: string;
setUploadMaxSize: (value: string) => void;
slashEnabled: boolean;
setSlashEnabled: (value: boolean) => void;
slashSuggest: boolean;
setSlashSuggest: (value: boolean) => void;
slashPartial: boolean;
setSlashPartial: (value: boolean) => void;
slashPrefix: string;
setSlashPrefix: (value: string) => void;
}
export function SystemSettingsSkillsCard({
uploadMaxSize,
setUploadMaxSize,
slashEnabled,
setSlashEnabled,
slashSuggest,
setSlashSuggest,
slashPartial,
setSlashPartial,
slashPrefix,
setSlashPrefix,
}: SystemSettingsSkillsCardProps) {
const { t } = useTranslation("system-settings");
@@ -24,7 +41,7 @@ export function SystemSettingsSkillsCard({
</CardTitle>
<CardDescription>{t("skills.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-0">
<CardContent className="space-y-4 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">
@@ -42,7 +59,71 @@ export function SystemSettingsSkillsCard({
className="w-24 shrink-0 text-base md:text-sm"
/>
</div>
<div className="space-y-3 border-t pt-4">
<SkillSwitchRow
id="skillSlashEnabled"
label={t("skills.slashEnabled")}
hint={t("skills.slashEnabledHint")}
checked={slashEnabled}
onCheckedChange={setSlashEnabled}
/>
<SkillSwitchRow
id="skillSlashSuggest"
label={t("skills.slashSuggest")}
hint={t("skills.slashSuggestHint")}
checked={slashSuggest}
onCheckedChange={setSlashSuggest}
/>
<SkillSwitchRow
id="skillSlashPartial"
label={t("skills.slashPartial")}
hint={t("skills.slashPartialHint")}
checked={slashPartial}
onCheckedChange={setSlashPartial}
/>
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor="skillSlashPrefix" className="text-sm font-medium">
{t("skills.slashPrefix")}
</Label>
<p className="text-xs text-muted-foreground">{t("skills.slashPrefixHint")}</p>
</div>
<Input
id="skillSlashPrefix"
maxLength={1}
value={slashPrefix}
onChange={(e) => setSlashPrefix(e.target.value.slice(0, 1))}
className="w-16 shrink-0 text-center text-base md:text-sm"
/>
</div>
</div>
</CardContent>
</Card>
);
}
function SkillSwitchRow({
id,
label,
hint,
checked,
onCheckedChange,
}: {
id: string;
label: string;
hint: string;
checked: boolean;
onCheckedChange: (value: boolean) => void;
}) {
return (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<Label htmlFor={id} className="text-sm font-medium">
{label}
</Label>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} />
</div>
);
}
@@ -66,9 +66,17 @@
},
"skills": {
"title": "Skills",
"description": "Tenant skill package upload limits.",
"description": "Tenant skill package and slash command settings.",
"maxUploadSize": "Max upload size",
"maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500."
"maxUploadSizeHint": "Per-file ZIP limit in MB. Allowed range: 1-500.",
"slashEnabled": "Enable slash commands",
"slashEnabledHint": "Detect /skill-name and /use skill-name at the start of user prompts.",
"slashSuggest": "Suggest similar skills",
"slashSuggestHint": "Show close matches when a requested skill is not found.",
"slashPartial": "Allow partial matching",
"slashPartialHint": "Let unique prefixes like /frontend activate matching skills.",
"slashPrefix": "Command prefix",
"slashPrefixHint": "Single character used before skill commands."
},
"compaction": {
"title": "Pending Message Compaction",
@@ -66,9 +66,17 @@
},
"skills": {
"title": "Skills",
"description": "Giới hạn tải lên gói skill theo tenant.",
"description": "Cài đặt gói skill và slash command 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."
"maxUploadSizeHint": "Giới hạn ZIP theo từng tệp, tính bằng MB. Khoảng cho phép: 1-500.",
"slashEnabled": "Bật slash command",
"slashEnabledHint": "Nhận diện /skill-name và /use skill-name ở đầu prompt.",
"slashSuggest": "Gợi ý skill gần đúng",
"slashSuggestHint": "Hiển thị các skill gần giống khi không tìm thấy skill được gọi.",
"slashPartial": "Cho phép khớp một phần",
"slashPartialHint": "Cho phép prefix duy nhất như /frontend kích hoạt skill tương ứng.",
"slashPrefix": "Prefix command",
"slashPrefixHint": "Một ký tự dùng trước skill command."
},
"compaction": {
"title": "Nén tin nhắn chờ",
@@ -66,9 +66,17 @@
},
"skills": {
"title": "技能",
"description": "租户技能包上传限制。",
"description": "租户技能包和斜杠命令设置。",
"maxUploadSize": "最大上传大小",
"maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。"
"maxUploadSizeHint": "单个 ZIP 文件限制,单位 MB。允许范围:1-500。",
"slashEnabled": "启用斜杠命令",
"slashEnabledHint": "在用户提示开头检测 /skill-name 和 /use skill-name。",
"slashSuggest": "建议相似技能",
"slashSuggestHint": "找不到请求的技能时显示接近匹配项。",
"slashPartial": "允许部分匹配",
"slashPartialHint": "允许像 /frontend 这样的唯一前缀激活匹配技能。",
"slashPrefix": "命令前缀",
"slashPrefixHint": "技能命令前使用的单个字符。"
},
"compaction": {
"title": "待处理消息压缩",