feat(command): extend slash command system with new commands and UX improvements

Add 9 new command groups (/model, /memory, /search, /browser, /usage,
/email, /heartbeat, /skill, /fs) and improve existing commands by hiding
internal UUIDs, resolving IDs to human-readable names in /settings, and
switching /schedule to name-based references.
This commit is contained in:
Acbox
2026-03-11 18:57:08 +08:00
parent 0ec211f3d0
commit ab82a72639
24 changed files with 2467 additions and 1 deletions
+88
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"os"
stdpath "path"
"strings"
"time"
@@ -34,6 +35,7 @@ import (
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
"github.com/memohai/memoh/internal/command"
"github.com/memohai/memoh/internal/config"
ctr "github.com/memohai/memoh/internal/containerd"
"github.com/memohai/memoh/internal/conversation"
@@ -423,6 +425,21 @@ func provideChannelRouter(
bindService *bind.Service,
mediaService *media.Service,
inboxService *inbox.Service,
subagentService *subagent.Service,
scheduleService *schedule.Service,
settingsService *settings.Service,
mcpConnService *mcp.ConnectionService,
modelsService *models.Service,
providersService *providers.Service,
memProvService *memprovider.Service,
searchProvService *searchproviders.Service,
browserCtxService *browsercontexts.Service,
emailService *emailpkg.Service,
emailOutboxService *emailpkg.OutboxService,
heartbeatService *heartbeat.Service,
queries *dbsqlc.Queries,
containerdHandler *handlers.ContainerdHandler,
manager *mcp.Manager,
rc *boot.RuntimeConfig,
) *inbound.ChannelInboundProcessor {
adapter, ok := registry.Get(qq.Type)
@@ -440,6 +457,26 @@ func provideChannelRouter(
processor.SetMediaService(mediaService)
processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub))
processor.SetInboxService(inboxService)
processor.SetCommandHandler(command.NewHandler(
log,
&command.BotMemberRoleAdapter{BotService: botService},
subagentService,
scheduleService,
settingsService,
mcpConnService,
inboxService,
modelsService,
providersService,
memProvService,
searchProvService,
browserCtxService,
emailService,
emailOutboxService,
heartbeatService,
queries,
&commandSkillLoaderAdapter{handler: containerdHandler},
&commandContainerFSAdapter{manager: manager},
))
return processor
}
@@ -928,3 +965,54 @@ func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, c
}
return reader, strings.TrimSpace(asset.Mime), nil
}
// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader.
type commandSkillLoaderAdapter struct {
handler *handlers.ContainerdHandler
}
func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]command.Skill, error) {
items, err := a.handler.LoadSkills(ctx, botID)
if err != nil {
return nil, err
}
skills := make([]command.Skill, len(items))
for i, item := range items {
skills[i] = command.Skill{Name: item.Name, Description: item.Description}
}
return skills, nil
}
// commandContainerFSAdapter bridges mcp.Manager to command.ContainerFS.
type commandContainerFSAdapter struct {
manager *mcp.Manager
}
func (a *commandContainerFSAdapter) ListDir(ctx context.Context, botID, dirPath string) ([]command.FSEntry, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return nil, err
}
entries, err := client.ListDir(ctx, dirPath, false)
if err != nil {
return nil, err
}
result := make([]command.FSEntry, len(entries))
for i, e := range entries {
name := stdpath.Base(e.GetPath())
result[i] = command.FSEntry{Name: name, IsDir: e.GetIsDir(), Size: e.GetSize()}
}
return result, nil
}
func (a *commandContainerFSAdapter) ReadFile(ctx context.Context, botID, filePath string) (string, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return "", err
}
resp, err := client.ReadFile(ctx, filePath, 0, 0)
if err != nil {
return "", err
}
return resp.GetContent(), nil
}
+88 -1
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/http"
"os"
stdpath "path"
"strings"
"time"
@@ -34,6 +35,7 @@ import (
"github.com/memohai/memoh/internal/channel/identities"
"github.com/memohai/memoh/internal/channel/inbound"
"github.com/memohai/memoh/internal/channel/route"
"github.com/memohai/memoh/internal/command"
"github.com/memohai/memoh/internal/config"
ctr "github.com/memohai/memoh/internal/containerd"
"github.com/memohai/memoh/internal/conversation"
@@ -46,8 +48,10 @@ import (
emailmailgun "github.com/memohai/memoh/internal/email/adapters/mailgun"
"github.com/memohai/memoh/internal/handlers"
"github.com/memohai/memoh/internal/healthcheck"
"github.com/memohai/memoh/internal/browsercontexts"
channelchecker "github.com/memohai/memoh/internal/healthcheck/checkers/channel"
mcpchecker "github.com/memohai/memoh/internal/healthcheck/checkers/mcp"
"github.com/memohai/memoh/internal/heartbeat"
"github.com/memohai/memoh/internal/inbox"
"github.com/memohai/memoh/internal/logger"
"github.com/memohai/memoh/internal/mcp"
@@ -122,8 +126,11 @@ func runServe() {
provideChannelManager,
provideChannelLifecycleService,
provideChatResolver,
browsercontexts.NewService,
provideScheduleTriggerer,
schedule.NewService,
provideHeartbeatTriggerer,
heartbeat.NewService,
provideContainerdHandler,
provideFederationGateway,
provideToolGatewayService,
@@ -162,6 +169,7 @@ func runServe() {
fx.Invoke(
startMemoryProviderBootstrap,
startScheduleService,
startHeartbeatService,
startChannelManager,
startEmailManager,
startContainerReconciliation,
@@ -267,6 +275,10 @@ func provideScheduleTriggerer(resolver *flow.Resolver) schedule.Triggerer {
return flow.NewScheduleGateway(resolver)
}
func provideHeartbeatTriggerer(resolver *flow.Resolver) heartbeat.Triggerer {
return flow.NewHeartbeatGateway(resolver)
}
func provideChatResolver(log *slog.Logger, cfg config.Config, modelsService *models.Service, queries *dbsqlc.Queries, chatService *conversation.Service, msgService *message.DBService, settingsService *settings.Service, mediaService *media.Service, containerdHandler *handlers.ContainerdHandler, inboxService *inbox.Service, memoryRegistry *memprovider.Registry) *flow.Resolver {
resolver := flow.NewResolver(log, modelsService, queries, chatService, msgService, settingsService, cfg.AgentGateway.BaseURL(), 120*time.Second)
resolver.SetMemoryRegistry(memoryRegistry)
@@ -292,11 +304,31 @@ func provideChannelRegistry(log *slog.Logger, hub *local.RouteHub, mediaService
return registry
}
func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor {
func provideChannelRouter(log *slog.Logger, registry *channel.Registry, hub *local.RouteHub, routeService *route.DBService, msgService *message.DBService, resolver *flow.Resolver, identityService *identities.Service, botService *bots.Service, policyService *policy.Service, preauthService *preauth.Service, bindService *bind.Service, mediaService *media.Service, inboxService *inbox.Service, subagentService *subagent.Service, scheduleService *schedule.Service, settingsService *settings.Service, mcpConnService *mcp.ConnectionService, modelsService *models.Service, providersService *providers.Service, memProvService *memprovider.Service, searchProvService *searchproviders.Service, browserCtxService *browsercontexts.Service, emailService *emailpkg.Service, emailOutboxService *emailpkg.OutboxService, heartbeatService *heartbeat.Service, queries *dbsqlc.Queries, containerdHandler *handlers.ContainerdHandler, manager *mcp.Manager, rc *boot.RuntimeConfig) *inbound.ChannelInboundProcessor {
processor := inbound.NewChannelInboundProcessor(log, registry, routeService, msgService, resolver, identityService, botService, policyService, preauthService, bindService, rc.JwtSecret, 5*time.Minute)
processor.SetMediaService(mediaService)
processor.SetStreamObserver(local.NewRouteHubBroadcaster(hub))
processor.SetInboxService(inboxService)
processor.SetCommandHandler(command.NewHandler(
log,
&command.BotMemberRoleAdapter{BotService: botService},
subagentService,
scheduleService,
settingsService,
mcpConnService,
inboxService,
modelsService,
providersService,
memProvService,
searchProvService,
browserCtxService,
emailService,
emailOutboxService,
heartbeatService,
queries,
&commandSkillLoaderAdapter{handler: containerdHandler},
&commandContainerFSAdapter{manager: manager},
))
return processor
}
@@ -515,6 +547,10 @@ func startScheduleService(lc fx.Lifecycle, scheduleService *schedule.Service) {
lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return scheduleService.Bootstrap(ctx) }})
}
func startHeartbeatService(lc fx.Lifecycle, heartbeatService *heartbeat.Service) {
lc.Append(fx.Hook{OnStart: func(ctx context.Context) error { return heartbeatService.Bootstrap(ctx) }})
}
func startChannelManager(lc fx.Lifecycle, channelManager *channel.Manager) {
ctx, cancel := context.WithCancel(context.Background())
lc.Append(fx.Hook{
@@ -818,3 +854,54 @@ func (a *gatewayAssetLoaderAdapter) OpenForGateway(ctx context.Context, botID, c
}
return reader, strings.TrimSpace(asset.Mime), nil
}
// commandSkillLoaderAdapter bridges handlers.ContainerdHandler to command.SkillLoader.
type commandSkillLoaderAdapter struct {
handler *handlers.ContainerdHandler
}
func (a *commandSkillLoaderAdapter) LoadSkills(ctx context.Context, botID string) ([]command.Skill, error) {
items, err := a.handler.LoadSkills(ctx, botID)
if err != nil {
return nil, err
}
skills := make([]command.Skill, len(items))
for i, item := range items {
skills[i] = command.Skill{Name: item.Name, Description: item.Description}
}
return skills, nil
}
// commandContainerFSAdapter bridges mcp.Manager to command.ContainerFS.
type commandContainerFSAdapter struct {
manager *mcp.Manager
}
func (a *commandContainerFSAdapter) ListDir(ctx context.Context, botID, dirPath string) ([]command.FSEntry, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return nil, err
}
entries, err := client.ListDir(ctx, dirPath, false)
if err != nil {
return nil, err
}
result := make([]command.FSEntry, len(entries))
for i, e := range entries {
name := stdpath.Base(e.GetPath())
result[i] = command.FSEntry{Name: name, IsDir: e.GetIsDir(), Size: e.GetSize()}
}
return result, nil
}
func (a *commandContainerFSAdapter) ReadFile(ctx context.Context, botID, filePath string) (string, error) {
client, err := a.manager.MCPClient(ctx, botID)
if err != nil {
return "", err
}
resp, err := client.ReadFile(ctx, filePath, 0, 0)
if err != nil {
return "", err
}
return resp.GetContent(), nil
}