mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
33e18e7e64
* feat(skills): add effective skill resolution and actions * refactor(workspace): normalize skill-related env and prompt * chore(api): regenerate skills OpenAPI and SDK artifacts * feat(web): surface effective skill state in console * test(skills): cover API and runtime effective state * fix(web): show adopt action for discovered skills * fix(web): align skill header and show stateful visibility icon * refactor(web): compact skill metadata on narrow layouts * fix(web): constrain long skill text in cards * refactor(skills): narrow default discovery roots * fix(skills): harden managed skill path validation * feat: add path in the results of `use_skill` --------- Co-authored-by: Acbox <acbox0328@gmail.com>
248 lines
7.3 KiB
Go
248 lines
7.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
skillset "github.com/memohai/memoh/internal/skills"
|
|
)
|
|
|
|
type SkillItem struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
Raw string `json:"raw"`
|
|
SourcePath string `json:"source_path,omitempty"`
|
|
SourceRoot string `json:"source_root,omitempty"`
|
|
SourceKind string `json:"source_kind,omitempty"`
|
|
Managed bool `json:"managed,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
ShadowedBy string `json:"shadowed_by,omitempty"`
|
|
}
|
|
|
|
type SkillsResponse struct {
|
|
Skills []SkillItem `json:"skills"`
|
|
}
|
|
|
|
type SkillsUpsertRequest struct {
|
|
Skills []string `json:"skills"`
|
|
}
|
|
|
|
type SkillsDeleteRequest struct {
|
|
Names []string `json:"names"`
|
|
}
|
|
|
|
type SkillsActionRequest struct {
|
|
Action string `json:"action"`
|
|
TargetPath string `json:"target_path"`
|
|
}
|
|
|
|
type skillsOpResponse struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
// ListSkills godoc
|
|
// @Summary List skills from the bot container
|
|
// @Tags containerd
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Success 200 {object} SkillsResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/container/skills [get].
|
|
func (h *ContainerdHandler) ListSkills(c echo.Context) error {
|
|
botID, err := h.requireBotAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
skills, err := h.listSkillsFromContainer(c.Request().Context(), botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
|
}
|
|
return c.JSON(http.StatusOK, SkillsResponse{Skills: skills})
|
|
}
|
|
|
|
// UpsertSkills godoc
|
|
// @Summary Upload skills into Memoh-managed directory
|
|
// @Tags containerd
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body SkillsUpsertRequest true "Skills payload"
|
|
// @Success 200 {object} skillsOpResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/container/skills [post].
|
|
func (h *ContainerdHandler) UpsertSkills(c echo.Context) error {
|
|
botID, err := h.requireBotAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req SkillsUpsertRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
if len(req.Skills) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "skills is required")
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
client, err := h.getGRPCClient(ctx, botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
|
|
}
|
|
|
|
for _, raw := range req.Skills {
|
|
parsed := skillset.ParseFile(raw, "")
|
|
dirPath, dirErr := skillset.ManagedSkillDirForName(parsed.Name)
|
|
if dirErr != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "skill must have a valid name in YAML frontmatter")
|
|
}
|
|
if err := client.Mkdir(ctx, dirPath); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("mkdir failed: %v", err))
|
|
}
|
|
filePath := path.Join(dirPath, "SKILL.md")
|
|
if err := client.WriteFile(ctx, filePath, []byte(raw)); err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("write failed: %v", err))
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, skillsOpResponse{OK: true})
|
|
}
|
|
|
|
// DeleteSkills godoc
|
|
// @Summary Delete Memoh-managed skills
|
|
// @Tags containerd
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body SkillsDeleteRequest true "Delete skills payload"
|
|
// @Success 200 {object} skillsOpResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/container/skills [delete].
|
|
func (h *ContainerdHandler) DeleteSkills(c echo.Context) error {
|
|
botID, err := h.requireBotAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req SkillsDeleteRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
if len(req.Names) == 0 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "names is required")
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
client, err := h.getGRPCClient(ctx, botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
|
|
}
|
|
|
|
for _, name := range req.Names {
|
|
skillName := strings.TrimSpace(name)
|
|
managedDir, dirErr := skillset.ManagedSkillDirForName(skillName)
|
|
if dirErr != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid skill name")
|
|
}
|
|
if _, statErr := client.Stat(ctx, managedDir); statErr != nil {
|
|
return fsHTTPError(statErr)
|
|
}
|
|
if err := client.DeleteFile(ctx, managedDir, true); err != nil {
|
|
return fsHTTPError(err)
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, skillsOpResponse{OK: true})
|
|
}
|
|
|
|
// ApplySkillAction godoc
|
|
// @Summary Apply an action to a discovered or managed skill source
|
|
// @Tags containerd
|
|
// @Param bot_id path string true "Bot ID"
|
|
// @Param payload body SkillsActionRequest true "Skill action payload"
|
|
// @Success 200 {object} skillsOpResponse
|
|
// @Failure 400 {object} ErrorResponse
|
|
// @Failure 404 {object} ErrorResponse
|
|
// @Failure 500 {object} ErrorResponse
|
|
// @Router /bots/{bot_id}/container/skills/actions [post].
|
|
func (h *ContainerdHandler) ApplySkillAction(c echo.Context) error {
|
|
botID, err := h.requireBotAccess(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var req SkillsActionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
ctx := c.Request().Context()
|
|
client, err := h.getGRPCClient(ctx, botID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("container not reachable: %v", err))
|
|
}
|
|
|
|
if err := skillset.ApplyAction(ctx, client, skillset.ActionRequest{
|
|
Action: req.Action,
|
|
TargetPath: req.TargetPath,
|
|
}); err != nil {
|
|
return fsHTTPError(err)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, skillsOpResponse{OK: true})
|
|
}
|
|
|
|
// LoadSkills loads the effective skills from the container for the given bot.
|
|
func (h *ContainerdHandler) LoadSkills(ctx context.Context, botID string) ([]SkillItem, error) {
|
|
client, err := h.getGRPCClient(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := skillset.LoadEffective(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return skillItemsFromEntries(items), nil
|
|
}
|
|
|
|
func (h *ContainerdHandler) listSkillsFromContainer(ctx context.Context, botID string) ([]SkillItem, error) {
|
|
client, err := h.getGRPCClient(ctx, botID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, err := skillset.List(ctx, client)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return skillItemsFromEntries(items), nil
|
|
}
|
|
|
|
func skillItemsFromEntries(entries []skillset.Entry) []SkillItem {
|
|
items := make([]SkillItem, len(entries))
|
|
for i, entry := range entries {
|
|
items[i] = SkillItem{
|
|
Name: entry.Name,
|
|
Description: entry.Description,
|
|
Content: entry.Content,
|
|
Metadata: entry.Metadata,
|
|
Raw: entry.Raw,
|
|
SourcePath: entry.SourcePath,
|
|
SourceRoot: entry.SourceRoot,
|
|
SourceKind: entry.SourceKind,
|
|
Managed: entry.Managed,
|
|
State: entry.State,
|
|
ShadowedBy: entry.ShadowedBy,
|
|
}
|
|
}
|
|
return items
|
|
}
|