multica/server/internal/handler/skill.go
Naiyuan Qing 06122dfe9e merge: resolve conflicts with main (skills feature)
Merge origin/main which added the skills system (structured skills
with meta skill runtime injection). Resolve 4 conflicts:

- workspace/store.ts: keep both skills state + issue/inbox fetch
- types/index.ts: keep Skill types + our event exports
- handler/agent.go: merge visibility filtering + skills batch loading
- pnpm-lock.yaml: accept main's lockfile with skills deps

Also fix skill.go: migrate h.broadcast → h.publish (event bus)
to match our architecture where all WS events go through the bus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:43:21 +08:00

540 lines
14 KiB
Go

package handler
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// --- Response structs ---
type SkillResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
Config any `json:"config"`
CreatedBy *string `json:"created_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SkillFileResponse struct {
ID string `json:"id"`
SkillID string `json:"skill_id"`
Path string `json:"path"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type SkillWithFilesResponse struct {
SkillResponse
Files []SkillFileResponse `json:"files"`
}
func skillToResponse(s db.Skill) SkillResponse {
var config any
if s.Config != nil {
json.Unmarshal(s.Config, &config)
}
if config == nil {
config = map[string]any{}
}
return SkillResponse{
ID: uuidToString(s.ID),
WorkspaceID: uuidToString(s.WorkspaceID),
Name: s.Name,
Description: s.Description,
Content: s.Content,
Config: config,
CreatedBy: uuidToPtr(s.CreatedBy),
CreatedAt: timestampToString(s.CreatedAt),
UpdatedAt: timestampToString(s.UpdatedAt),
}
}
func skillFileToResponse(f db.SkillFile) SkillFileResponse {
return SkillFileResponse{
ID: uuidToString(f.ID),
SkillID: uuidToString(f.SkillID),
Path: f.Path,
Content: f.Content,
CreatedAt: timestampToString(f.CreatedAt),
UpdatedAt: timestampToString(f.UpdatedAt),
}
}
// --- Request structs ---
type CreateSkillRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
Config any `json:"config"`
Files []CreateSkillFileRequest `json:"files,omitempty"`
}
type CreateSkillFileRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
type UpdateSkillRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Content *string `json:"content"`
Config any `json:"config"`
Files []CreateSkillFileRequest `json:"files,omitempty"`
}
type SetAgentSkillsRequest struct {
SkillIDs []string `json:"skill_ids"`
}
// --- Helpers ---
// validateFilePath checks that a file path is safe (no traversal, no absolute paths).
func validateFilePath(p string) bool {
if p == "" {
return false
}
if filepath.IsAbs(p) {
return false
}
cleaned := filepath.Clean(p)
if strings.HasPrefix(cleaned, "..") {
return false
}
return true
}
func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) {
skill, err := h.Queries.GetSkill(r.Context(), parseUUID(id))
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "skill not found")
} else {
writeError(w, http.StatusInternalServerError, "failed to load skill")
}
return skill, false
}
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(skill.WorkspaceID), "skill not found"); !ok {
return skill, false
}
return skill, true
}
// --- Skill CRUD ---
func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
return
}
skills, err := h.Queries.ListSkillsByWorkspace(r.Context(), parseUUID(workspaceID))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list skills")
return
}
resp := make([]SkillResponse, len(skills))
for i, s := range skills {
resp[i] = skillToResponse(s)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list skill files")
return
}
fileResps := make([]SkillFileResponse, len(files))
for i, f := range files {
fileResps[i] = skillFileToResponse(f)
}
writeJSON(w, http.StatusOK, SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
})
}
func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
workspaceID := resolveWorkspaceID(r)
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
return
}
creatorID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
for _, f := range req.Files {
if !validateFilePath(f.Path) {
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
return
}
}
config, _ := json.Marshal(req.Config)
if req.Config == nil {
config = []byte("{}")
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
skill, err := qtx.CreateSkill(r.Context(), db.CreateSkillParams{
WorkspaceID: parseUUID(workspaceID),
Name: req.Name,
Description: req.Description,
Content: req.Content,
Config: config,
CreatedBy: parseUUID(creatorID),
})
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a skill with this name already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
return
}
fileResps := make([]SkillFileResponse, 0, len(req.Files))
for _, f := range req.Files {
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: f.Path,
Content: f.Content,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create skill file: "+err.Error())
return
}
fileResps = append(fileResps, skillFileToResponse(sf))
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit")
return
}
resp := SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
}
h.publish("skill:created", workspaceID, "member", creatorID, map[string]any{"skill": resp})
writeJSON(w, http.StatusCreated, resp)
}
func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
return
}
var req UpdateSkillRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
for _, f := range req.Files {
if !validateFilePath(f.Path) {
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
return
}
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
params := db.UpdateSkillParams{
ID: parseUUID(id),
}
if req.Name != nil {
params.Name = pgtype.Text{String: *req.Name, Valid: true}
}
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}
}
if req.Content != nil {
params.Content = pgtype.Text{String: *req.Content, Valid: true}
}
if req.Config != nil {
config, _ := json.Marshal(req.Config)
params.Config = config
}
skill, err = qtx.UpdateSkill(r.Context(), params)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "a skill with this name already exists")
return
}
writeError(w, http.StatusInternalServerError, "failed to update skill: "+err.Error())
return
}
// If files are provided, replace all files.
var fileResps []SkillFileResponse
if req.Files != nil {
if err := qtx.DeleteSkillFilesBySkill(r.Context(), skill.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete old skill files")
return
}
fileResps = make([]SkillFileResponse, 0, len(req.Files))
for _, f := range req.Files {
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: f.Path,
Content: f.Content,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
return
}
fileResps = append(fileResps, skillFileToResponse(sf))
}
} else {
files, _ := qtx.ListSkillFiles(r.Context(), skill.ID)
fileResps = make([]SkillFileResponse, len(files))
for i, f := range files {
fileResps[i] = skillFileToResponse(f)
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit")
return
}
resp := SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
}
h.publish("skill:updated", resolveWorkspaceID(r), "member", requestUserID(r), map[string]any{"skill": resp})
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
return
}
if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete skill")
return
}
h.publish("skill:deleted", uuidToString(skill.WorkspaceID), "member", requestUserID(r), map[string]any{"skill_id": id})
w.WriteHeader(http.StatusNoContent)
}
// --- Skill File endpoints ---
func (h *Handler) ListSkillFiles(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list skill files")
return
}
resp := make([]SkillFileResponse, len(files))
for i, f := range files {
resp[i] = skillFileToResponse(f)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
return
}
var req CreateSkillFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validateFilePath(req.Path) {
writeError(w, http.StatusBadRequest, "invalid file path")
return
}
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: req.Path,
Content: req.Content,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
return
}
writeJSON(w, http.StatusOK, skillFileToResponse(sf))
}
func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
skill, ok := h.loadSkillForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
return
}
fileID := chi.URLParam(r, "fileId")
if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete skill file")
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Agent-Skill junction ---
func (h *Handler) ListAgentSkills(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
return
}
resp := make([]SkillResponse, len(skills))
for i, s := range skills {
resp[i] = skillToResponse(s)
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
agent, ok := h.loadAgentForUser(w, r, id)
if !ok {
return
}
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
return
}
var req SetAgentSkillsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
if err := qtx.RemoveAllAgentSkills(r.Context(), agent.ID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to clear agent skills")
return
}
for _, skillID := range req.SkillIDs {
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
AgentID: agent.ID,
SkillID: parseUUID(skillID),
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error())
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit")
return
}
// Return the updated skills list.
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
return
}
resp := make([]SkillResponse, len(skills))
for i, s := range skills {
resp[i] = skillToResponse(s)
}
h.publish("agent:status", uuidToString(agent.WorkspaceID), "member", requestUserID(r), map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp})
writeJSON(w, http.StatusOK, resp)
}