From c99f7e1fa4d4d52dce30889e71ae92db7b3c8bcd Mon Sep 17 00:00:00 2001 From: Goon Date: Sun, 24 May 2026 16:39:44 +0700 Subject: [PATCH] feat(skills): add slash command activation --- cmd/gateway_managed.go | 1 + docs/07-bootstrap-skills-memory.md | 24 +++ docs/14-skills-runtime.md | 5 + docs/18-http-api.md | 6 + docs/project-changelog.md | 11 ++ internal/agent/loop_history.go | 2 + internal/agent/loop_types.go | 26 ++-- internal/agent/resolver.go | 5 +- .../agent/skill_slash_command_guidance.go | 65 +++++++++ .../agent/skill_slash_command_matching.go | 124 ++++++++++++++++ .../agent/skill_slash_command_similarity.go | 102 +++++++++++++ internal/agent/skill_slash_commands.go | 130 +++++++++++++++++ internal/agent/skill_slash_commands_test.go | 138 ++++++++++++++++++ internal/config/config.go | 52 ++++++- internal/config/config_load.go | 24 +++ internal/config/config_load_test.go | 84 +++++++++++ internal/config/config_system.go | 4 + internal/gateway/methods/config.go | 64 +++++--- .../layout/system-settings-constants.ts | 8 + .../layout/system-settings-modal.tsx | 24 +++ .../layout/system-settings-skills-card.tsx | 83 ++++++++++- .../src/i18n/locales/en/system-settings.json | 12 +- .../src/i18n/locales/vi/system-settings.json | 12 +- .../src/i18n/locales/zh/system-settings.json | 12 +- 24 files changed, 978 insertions(+), 40 deletions(-) create mode 100644 internal/agent/skill_slash_command_guidance.go create mode 100644 internal/agent/skill_slash_command_matching.go create mode 100644 internal/agent/skill_slash_command_similarity.go create mode 100644 internal/agent/skill_slash_commands.go create mode 100644 internal/agent/skill_slash_commands_test.go diff --git a/cmd/gateway_managed.go b/cmd/gateway_managed.go index 798bff9f..0ae38647 100644 --- a/cmd/gateway_managed.go +++ b/cmd/gateway_managed.go @@ -209,6 +209,7 @@ func wireExtras( ToolPolicy: toolPE, Skills: skillsLoader, SkillAccessStore: skillAccessStore, + SkillSlashCommands: appCfg.Skills.SlashCommands, HasMemory: hasMemory, TraceCollector: traceCollector, EnsureUserProfile: ensureUserProfile, diff --git a/docs/07-bootstrap-skills-memory.md b/docs/07-bootstrap-skills-memory.md index cc2af0fb..62c721a0 100644 --- a/docs/07-bootstrap-skills-memory.md +++ b/docs/07-bootstrap-skills-memory.md @@ -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 | +|---------|----------| +| `/ prompt` | Activates the skill by slug and treats `prompt` as the skill input | +| `/use prompt` | Activates the skill by slug or display name | +| `/list-skills` | Shows available skills for the current agent context | +| `/help ` | 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. diff --git a/docs/14-skills-runtime.md b/docs/14-skills-runtime.md index 7b91ff02..ea0db305 100644 --- a/docs/14-skills-runtime.md +++ b/docs/14-skills-runtime.md @@ -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 `/` or `/use `, 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) diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 3d4ef5ab..e5227b98 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -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 `/ prompt`, +`/use prompt`, `/list-skills`, and `/help `. + | Method | Path | Description | |--------|------|-------------| | `POST` | `/v1/skills/{id}/grants/agent` | Grant skill to agent | diff --git a/docs/project-changelog.md b/docs/project-changelog.md index d3f7094f..c5c15726 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -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 `/`, `/use `, `/list-skills`, and `/help `. +- 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** diff --git a/internal/agent/loop_history.go b/internal/agent/loop_history.go index da91d3b0..36cf064c 100644 --- a/internal/agent/loop_history.go +++ b/internal/agent/loop_history.go @@ -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. diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index c62d8c3d..816e06a1 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -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, diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 5a82fb8d..823e17fc 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -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()), diff --git a/internal/agent/skill_slash_command_guidance.go b/internal/agent/skill_slash_command_guidance.go new file mode 100644 index 00000000..6a652e92 --- /dev/null +++ b/internal/agent/skill_slash_command_guidance.go @@ -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 +} diff --git a/internal/agent/skill_slash_command_matching.go b/internal/agent/skill_slash_command_matching.go new file mode 100644 index 00000000..68a4bb1c --- /dev/null +++ b/internal/agent/skill_slash_command_matching.go @@ -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):])) +} diff --git a/internal/agent/skill_slash_command_similarity.go b/internal/agent/skill_slash_command_similarity.go new file mode 100644 index 00000000..8b3c9fed --- /dev/null +++ b/internal/agent/skill_slash_command_similarity.go @@ -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)] +} diff --git a/internal/agent/skill_slash_commands.go b/internal/agent/skill_slash_commands.go new file mode 100644 index 00000000..c9b1f34e --- /dev/null +++ b/internal/agent/skill_slash_commands.go @@ -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 + } +} diff --git a/internal/agent/skill_slash_commands_test.go b/internal/agent/skill_slash_commands_test.go new file mode 100644 index 00000000..d67c0d7b --- /dev/null +++ b/internal/agent/skill_slash_commands_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index b4d7102d..86d6f0d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 18cc3e1b..e215bad3 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -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) diff --git a/internal/config/config_load_test.go b/internal/config/config_load_test.go index a1fe6f9c..912c04bc 100644 --- a/internal/config/config_load_test.go +++ b/internal/config/config_load_test.go @@ -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 diff --git a/internal/config/config_system.go b/internal/config/config_system.go index 6b2a9365..3f046809 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -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) diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index d27c9f45..481181b3 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -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", + }, }, } diff --git a/ui/web/src/components/layout/system-settings-constants.ts b/ui/web/src/components/layout/system-settings-constants.ts index 7d0e3174..9cbcfc5d 100644 --- a/ui/web/src/components/layout/system-settings-constants.ts +++ b/ui/web/src/components/layout/system-settings-constants.ts @@ -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 { diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index a7ae8d74..4bc0aca9 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -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, @@ -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 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({ {t("skills.description")} - +
+
+ + + +
+
+ +

{t("skills.slashPrefixHint")}

+
+ setSlashPrefix(e.target.value.slice(0, 1))} + className="w-16 shrink-0 text-center text-base md:text-sm" + /> +
+
); } + +function SkillSwitchRow({ + id, + label, + hint, + checked, + onCheckedChange, +}: { + id: string; + label: string; + hint: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; +}) { + return ( +
+
+ +

{hint}

+
+ +
+ ); +} diff --git a/ui/web/src/i18n/locales/en/system-settings.json b/ui/web/src/i18n/locales/en/system-settings.json index 2b9c96a3..911e74b1 100644 --- a/ui/web/src/i18n/locales/en/system-settings.json +++ b/ui/web/src/i18n/locales/en/system-settings.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/system-settings.json b/ui/web/src/i18n/locales/vi/system-settings.json index ebb8d3fc..4100d6ef 100644 --- a/ui/web/src/i18n/locales/vi/system-settings.json +++ b/ui/web/src/i18n/locales/vi/system-settings.json @@ -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ờ", diff --git a/ui/web/src/i18n/locales/zh/system-settings.json b/ui/web/src/i18n/locales/zh/system-settings.json index 0b81a363..7f951fdc 100644 --- a/ui/web/src/i18n/locales/zh/system-settings.json +++ b/ui/web/src/i18n/locales/zh/system-settings.json @@ -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": "待处理消息压缩",