ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
2299 lines
71 KiB
Go
2299 lines
71 KiB
Go
package server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"text/template/parse"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Autumn-27/artex/agent"
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/norma/llm"
|
|
"github.com/Autumn-27/norma/skill"
|
|
)
|
|
|
|
// validSkillName checks the agentskills.io name constraints, widened so a skill can
|
|
// also be named in Chinese (or any other script): ASCII must stay lowercase
|
|
// alphanumeric + hyphens, while non-ASCII letters/digits are accepted as-is.
|
|
// 1-64 runes, must start with a letter, no leading/trailing/consecutive hyphens.
|
|
// The name doubles as a directory name under skillDir, so nothing that could carry a
|
|
// path (separators, dots, spaces, control characters) is allowed through.
|
|
func validSkillName(name string) bool {
|
|
if name == "" || !utf8.ValidString(name) {
|
|
return false
|
|
}
|
|
rs := []rune(name)
|
|
if len(rs) > 64 {
|
|
return false
|
|
}
|
|
isLetter := func(r rune) bool {
|
|
return (r >= 'a' && r <= 'z') || (r > unicode.MaxASCII && unicode.IsLetter(r))
|
|
}
|
|
if !isLetter(rs[0]) || rs[len(rs)-1] == '-' {
|
|
return false
|
|
}
|
|
for _, r := range rs {
|
|
switch {
|
|
case isLetter(r), r >= '0' && r <= '9', r == '-':
|
|
case r > unicode.MaxASCII && unicode.IsDigit(r):
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return !strings.Contains(name, "--")
|
|
}
|
|
|
|
// reAgentKey mirrors the agents.key DB check: lowercase letter start, then
|
|
// lowercase letters / digits / underscores.
|
|
var reAgentKey = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
|
|
// F3b: 사용자(관리 콘솔 UI)에게 writeErr/writeJSON 으로 노출되는 응답 문구만 한국어로 모은다.
|
|
// 두뇌 段[A]·에이전트 도구 설명·로그(log.Printf)·코드 주석은 BRIEF 방침대로 원문을 보존하고,
|
|
// 식별자·필드명(key·llm_profile_id·file·SKILL.md·name)·enum·%s/%d·err.Error() 도 원문 그대로 둔다.
|
|
const (
|
|
// 모든 관리 핸들러가 거치는 공유 DB 게이트(pg).
|
|
errMgmtPGUnavailable = "관리 콘솔 데이터 소스(PostgreSQL)에 연결되어 있지 않습니다"
|
|
|
|
// 작업 삭제
|
|
errMgmtTaskIDInvalid = "작업 id 가 유효하지 않습니다"
|
|
errMgmtTaskDeleting = "작업을 삭제하는 중입니다"
|
|
errMgmtTaskHasAgents = "실행 중인 에이전트가 남아 있어 작업 삭제를 취소했습니다"
|
|
|
|
// 에이전트 CRUD
|
|
errMgmtAgentKeyFormat = "key 는 소문자로 시작하고, 소문자·숫자·밑줄만 사용할 수 있습니다"
|
|
errMgmtNameEmpty = "이름은 비워 둘 수 없습니다"
|
|
errMgmtAgentKeyExists = "이미 존재하는 key 입니다"
|
|
errMgmtBuiltinNoEditMeta = "내장 에이전트는 이름/설명을 수정할 수 없습니다"
|
|
errMgmtBuiltinNoDelete = "내장 에이전트는 삭제할 수 없습니다"
|
|
errMgmtLLMProfileIDFormat = "llm_profile_id 형식이 올바르지 않습니다"
|
|
errMgmtLLMProfileInvalid = "지정한 LLM 설정이 존재하지 않거나 유효하지 않습니다"
|
|
errMgmtNoBuiltinPrompt = "이 에이전트에는 내장 기본 프롬프트가 없어 복원할 수 없습니다"
|
|
|
|
// 도구 (뒤에 key 를 이어 붙임)
|
|
errMgmtToolNotFound = "도구를 찾을 수 없습니다: "
|
|
errMgmtNotBuiltinTool = "내장 도구가 아니거나 존재하지 않습니다: "
|
|
|
|
// MCP
|
|
errMgmtMCPNotFound = "MCP 를 찾을 수 없습니다"
|
|
errMgmtToolDiscoverFail = "도구 발견에 실패했습니다: " // 뒤에 err.Error()
|
|
|
|
// 스킬 업로드
|
|
errMgmtSkillNameInvalid = "스킬 이름이 올바르지 않습니다"
|
|
errMgmtSkillNoFile = "업로드 파일이 없거나(폼 필드 file) 크기 제한을 초과했습니다"
|
|
errMgmtSkillNoMDInZip = "압축 파일 안에서 SKILL.md 를 찾지 못했습니다"
|
|
errMgmtSkillReadMDFail = "SKILL.md 를 읽지 못했습니다: " // 뒤에 err.Error()
|
|
errMgmtSkillNamePre = "스킬 이름이 올바르지 않습니다(SKILL.md 의 name 필드에서 가져옴): "
|
|
errMgmtSkillNamePost = " (64자 이하, 글자로 시작, 소문자·숫자·하이픈 또는 한글 등 비 ASCII 글자만 쓸 수 있고, 공백·점·경로 구분자는 쓸 수 없습니다)"
|
|
// "이미 존재" 는 web system/skills/page.tsx 가 includes("이미 존재") 로 덮어쓰기 흐름을
|
|
// 띄우는 교차 스택 센티넬이다. 이 접두어를 바꾸면 프론트 미러도 함께 고쳐야 한다.
|
|
errMgmtSkillExistsPre = "이미 존재하는 스킬입니다: "
|
|
errMgmtSkillExistsPost = " (덮어쓰려면 확인 후 다시 시도하세요)"
|
|
errMgmtSkillZipBadPath = "압축 파일에 잘못된 경로가 들어 있습니다 " // 뒤에 항목 이름 + ": " + 사유
|
|
errMgmtSkillZipTooMany = "압축 파일에 파일이 너무 많습니다"
|
|
errMgmtSkillFileTooLarge = "파일이 너무 큽니다: " // 뒤에 경로
|
|
errMgmtSkillZipTooLarge = "압축을 푼 뒤 크기가 너무 큽니다"
|
|
errMgmtSkillNoMDAfter = "압축을 푼 뒤 SKILL.md 가 없습니다"
|
|
errMgmtSkillInstallFail = "설치에 실패했습니다: " // 뒤에 err.Error()
|
|
|
|
// LLM 설정 삭제
|
|
errMgmtLLMActiveDelete = "현재 활성화된 LLM 설정은 삭제할 수 없습니다. 먼저 다른 설정을 활성화하세요"
|
|
errMgmtLLMRefChanged = "LLM 설정이 작업이나 대화에서 수정되는 중입니다. 다시 시도하세요"
|
|
errMgmtLLMRefTimeout = "LLM 설정 참조가 해제되기를 기다리다 시간이 초과되었습니다. 다시 시도하세요"
|
|
|
|
// LLM 연결 테스트·모델 목록 조회 (pgListModels)
|
|
errMgmtNoAPIKey = "API Key 가 제공되지 않았습니다"
|
|
errMgmtBuildReqFail = "요청을 만들지 못했습니다: " // 뒤에 err.Error()
|
|
errMgmtReqFail = "요청에 실패했습니다: " // 뒤에 err.Error()
|
|
errMgmtAPIReturned = "API 가 %d 를 반환했습니다: %s" // fmt.Sprintf
|
|
errMgmtParseRespFail = "응답을 해석하지 못했습니다: " // 뒤에 err.Error()
|
|
errMgmtNoModelList = "모델 목록을 가져오지 못했습니다"
|
|
|
|
// 프롬프트 템플릿 편집기 (validateTemplate)
|
|
errMgmtTmplSyntax = "템플릿 문법 오류: " // 뒤에 err.Error()
|
|
errMgmtTmplVarPre = "변수 {{."
|
|
errMgmtTmplVarPost = "}} 는 이 에이전트의 허용 목록에 없습니다"
|
|
)
|
|
|
|
// pgReady returns the PG handle, or writes 503 and returns nil if unavailable.
|
|
func (s *Server) pg(w http.ResponseWriter) *db.DB {
|
|
if s.m.pg == nil {
|
|
writeErr(w, 503, errMgmtPGUnavailable)
|
|
return nil
|
|
}
|
|
return s.m.pg
|
|
}
|
|
|
|
func pathInt(r *http.Request, name string) (int64, bool) {
|
|
n, err := strconv.ParseInt(r.PathValue(name), 10, 64)
|
|
return n, err == nil
|
|
}
|
|
|
|
func decode(r *http.Request, v any) error { return json.NewDecoder(r.Body).Decode(v) }
|
|
|
|
// ---------- tasks (delete) ----------
|
|
|
|
const taskDeleteDrainTimeout = 10 * time.Second
|
|
|
|
func canonicalTaskID(raw string) (string, bool) {
|
|
n, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || n <= 0 {
|
|
return "", false
|
|
}
|
|
return strconv.FormatInt(n, 10), true
|
|
}
|
|
|
|
// beginTaskDelete serializes the delete barrier with pause, resume, admission,
|
|
// and FIFO reconciliation. Every lifecycle path rechecks deleting after taking
|
|
// concMu, so none can commit a contradictory state once this returns.
|
|
func (s *Server) beginTaskDelete(taskID string) bool {
|
|
s.concMu.Lock()
|
|
defer s.concMu.Unlock()
|
|
if s.engine.IsDeleting(taskID) {
|
|
return false
|
|
}
|
|
return s.engine.BeginDelete(taskID)
|
|
}
|
|
|
|
// abortTaskDelete restores the execution barrier from the committed task row,
|
|
// not from the in-memory state observed before deletion began. Keeping the task
|
|
// paused on an unreadable row is conservative: a later explicit resume can
|
|
// safely recover it without allowing work to escape an uncertain delete.
|
|
func (s *Server) abortTaskDelete(taskID string) {
|
|
s.concMu.Lock()
|
|
defer s.concMu.Unlock()
|
|
keepPaused := true
|
|
if id, err := strconv.ParseInt(taskID, 10, 64); err == nil && s.m != nil && s.m.pg != nil {
|
|
if persisted, getErr := s.m.pg.GetTask(id); getErr == nil && persisted != nil {
|
|
keepPaused = persisted.Paused || persisted.Queued
|
|
} else if task, ok := s.m.Task(taskID); ok {
|
|
state := task.lifecycleSnapshot()
|
|
keepPaused = state.Paused || state.Queued
|
|
if getErr != nil {
|
|
log.Printf("[task-delete] task %s 读取持久状态失败,使用内存状态恢复屏障: %v", taskID, getErr)
|
|
}
|
|
} else if getErr == nil {
|
|
// The request targeted a task that does not exist. Do not retain a
|
|
// synthetic pause entry after releasing its temporary delete barrier.
|
|
keepPaused = false
|
|
}
|
|
}
|
|
s.engine.AbortDelete(taskID, keepPaused)
|
|
}
|
|
|
|
func (s *Server) pgDeleteTask(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := canonicalTaskID(r.PathValue("id"))
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, errMgmtTaskIDInvalid)
|
|
return
|
|
}
|
|
var opts DeleteTaskOptions
|
|
if err := decode(r, &opts); err != nil && err != io.EOF {
|
|
writeErr(w, 400, "invalid JSON: "+err.Error())
|
|
return
|
|
}
|
|
if !s.beginTaskDelete(id) {
|
|
writeErr(w, http.StatusConflict, errMgmtTaskDeleting)
|
|
return
|
|
}
|
|
deleted := false
|
|
defer func() {
|
|
if !deleted {
|
|
s.abortTaskDelete(id)
|
|
}
|
|
}()
|
|
|
|
// Main-agent runs use a separate context from planner/workers. Cancel it, then
|
|
// wait until both execution domains have returned before removing transcripts.
|
|
s.cancelTaskChat(id, agent.AbortTaskDeleted)
|
|
drainCtx, cancelDrain := context.WithTimeout(r.Context(), taskDeleteDrainTimeout)
|
|
defer cancelDrain()
|
|
if err := s.waitTaskQuiescent(drainCtx, id); err != nil {
|
|
writeErr(w, http.StatusConflict, errMgmtTaskHasAgents)
|
|
return
|
|
}
|
|
|
|
if err := s.drainTaskSideQuestions(drainCtx, id); err != nil {
|
|
writeErr(w, http.StatusConflict, err.Error())
|
|
return
|
|
}
|
|
result, err := s.m.DeleteTask(id, opts)
|
|
if err != nil {
|
|
var committed *taskDeleteCommittedError
|
|
if errors.As(err, &committed) {
|
|
// PostgreSQL is already gone. Complete runtime teardown and return the
|
|
// auditable counts together with the post-commit cleanup warning.
|
|
s.engine.StopTask(id)
|
|
s.taskAgentMu.Lock()
|
|
delete(s.taskAgents, id)
|
|
s.taskAgentMu.Unlock()
|
|
deleted = true
|
|
writeCommittedTaskDelete(w, result, err)
|
|
return
|
|
}
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// Manager has removed the task from the registry, so no new API operation can
|
|
// resolve it. Now stop and join every task-owned Engine goroutine and clear its
|
|
// lifecycle maps before releasing the delete barrier.
|
|
s.engine.StopTask(id)
|
|
s.taskAgentMu.Lock()
|
|
delete(s.taskAgents, id)
|
|
s.taskAgentMu.Unlock()
|
|
deleted = true
|
|
writeJSON(w, 200, result)
|
|
}
|
|
|
|
func writeCommittedTaskDelete(w http.ResponseWriter, result DeleteTaskResult, err error) {
|
|
result.CleanupWarning = err.Error()
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
func (s *Server) waitTaskQuiescent(ctx context.Context, taskID string) error {
|
|
ticker := time.NewTicker(10 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
s.chatMu.Lock()
|
|
chatBusy := s.chatBusy[taskID]
|
|
s.chatMu.Unlock()
|
|
if s.engine.inflightCount(taskID) == 0 && !chatBusy {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- agents ----------
|
|
|
|
func (s *Server) pgListAgents(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
ags, err := pg.ListAgents()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
dtos := agentDTOs(ags)
|
|
// overlay per-agent binding counts (mcp/skill by id, tools by key) — best-effort.
|
|
if mcp, skill, tools, err := pg.AgentBindingCounts(); err == nil {
|
|
for i := range dtos {
|
|
dtos[i].McpCount = mcp[ags[i].ID]
|
|
dtos[i].SkillCount = skill[ags[i].ID]
|
|
dtos[i].ToolCount = tools[ags[i].Key]
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"agents": dtos})
|
|
}
|
|
|
|
// pgCreateAgent creates a CUSTOM conversational agent (builtin=false, role
|
|
// 'assistant') and seeds it a starter prompt so its editor isn't blank.
|
|
func (s *Server) pgCreateAgent(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req struct{ Key, Name, Description string }
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
req.Key, req.Name = strings.TrimSpace(req.Key), strings.TrimSpace(req.Name)
|
|
if !reAgentKey.MatchString(req.Key) {
|
|
writeErr(w, 400, errMgmtAgentKeyFormat)
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeErr(w, 400, errMgmtNameEmpty)
|
|
return
|
|
}
|
|
if exist, _ := pg.GetAgentByKey(req.Key); exist != nil {
|
|
writeErr(w, 409, errMgmtAgentKeyExists)
|
|
return
|
|
}
|
|
a, err := pg.CreateAgent(req.Key, req.Name, req.Description)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// starter prompt so the editor shows something editable from the start.
|
|
if err := pg.SeedPromptIfEmpty(a.ID, agent.DefaultAssistantPrompt); err != nil {
|
|
log.Printf("[agents] seed starter prompt for %s 失败: %v", a.Key, err)
|
|
}
|
|
writeJSON(w, 200, agentDTO(a))
|
|
}
|
|
|
|
// pgUpdateAgent updates a custom agent's name/description (built-in agents rejected).
|
|
func (s *Server) pgUpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if a.Builtin {
|
|
writeErr(w, 400, errMgmtBuiltinNoEditMeta)
|
|
return
|
|
}
|
|
var req struct{ Name, Description string }
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Name == "" {
|
|
writeErr(w, 400, errMgmtNameEmpty)
|
|
return
|
|
}
|
|
if err := pg.UpdateAgentMeta(a.Key, req.Name, req.Description); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// pgDeleteAgent removes a custom agent (built-in rejected). Prompts/vars/visibility
|
|
// cascade via FK; tool-binding cleanup is best-effort.
|
|
func (s *Server) pgDeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if a.Builtin {
|
|
writeErr(w, 400, errMgmtBuiltinNoDelete)
|
|
return
|
|
}
|
|
if err := pg.DeleteAgent(a.Key); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := pg.RemoveAgentFromToolBindings(a.Key); err != nil {
|
|
log.Printf("[agents] 清理 %s 工具绑定失败: %v", a.Key, err)
|
|
}
|
|
if err := pg.DeleteTriggersForAgent(a.Key); err != nil {
|
|
log.Printf("[agents] 清理 %s 触发器失败: %v", a.Key, err)
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": a.Key})
|
|
}
|
|
|
|
func (s *Server) agentByKey(w http.ResponseWriter, r *http.Request) (*db.DB, *db.Agent, bool) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return nil, nil, false
|
|
}
|
|
a, err := pg.GetAgentByKey(r.PathValue("key"))
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return nil, nil, false
|
|
}
|
|
if a == nil {
|
|
writeErr(w, 404, "agent not found")
|
|
return nil, nil, false
|
|
}
|
|
return pg, a, true
|
|
}
|
|
|
|
// pgSaveAgentConfig updates an agent's runtime config (currently max_turns) and
|
|
// re-applies the live LLM so the change takes effect immediately.
|
|
func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// All fields optional (pointers) so a partial patch (e.g. the triggers tab sending
|
|
// only trigger_* fields) leaves the untouched settings alone instead of resetting
|
|
// max_turns/run_seconds to 0.
|
|
var req struct {
|
|
MaxTurns *int `json:"max_turns"`
|
|
RunSeconds *int `json:"run_seconds"`
|
|
WebSearch *bool `json:"web_search"`
|
|
InteractiveShell *bool `json:"interactive_shell"`
|
|
// llm_profile_id 三态:字段缺省=不动;显式 null=解绑(跟随任务/全局);数字=绑定该 profile。
|
|
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
|
// P3 触发后处理策略(三者一起可选,提供任一即整体写入;未提供则不动)。
|
|
TriggerRunMode *string `json:"trigger_run_mode"`
|
|
TriggerMergeMode *string `json:"trigger_merge_mode"`
|
|
TriggerMaxParallel *int `json:"trigger_max_parallel"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
profileChanged := false
|
|
if req.LLMProfileID != nil { // key present (数字 或 null)
|
|
var id *int64
|
|
if err := json.Unmarshal(req.LLMProfileID, &id); err != nil {
|
|
writeErr(w, 400, errMgmtLLMProfileIDFormat)
|
|
return
|
|
}
|
|
if id != nil { // 绑定:校验目标 profile 有效
|
|
if _, ok := s.loadProfileConfig(*id); !ok {
|
|
writeErr(w, 400, errMgmtLLMProfileInvalid)
|
|
return
|
|
}
|
|
}
|
|
if err := pg.SetAgentLLMProfile(a.Key, id); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
profileChanged = true
|
|
}
|
|
if req.MaxTurns != nil {
|
|
mt := *req.MaxTurns
|
|
if mt < 0 {
|
|
mt = 0
|
|
}
|
|
if err := pg.SetAgentMaxTurns(a.Key, mt); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
if req.RunSeconds != nil {
|
|
rs := *req.RunSeconds
|
|
if rs < 0 {
|
|
rs = 0
|
|
}
|
|
if err := pg.SetAgentRunSeconds(a.Key, rs); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
if req.WebSearch != nil {
|
|
if err := pg.SetAgentWebSearch(a.Key, *req.WebSearch); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
if req.InteractiveShell != nil {
|
|
if err := pg.SetAgentInteractiveShell(a.Key, *req.InteractiveShell); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
// P3 触发策略:三者作为一组写入(SetAgentTriggerBehavior 一次写三列),缺的字段用
|
|
// 当前存量值回填,避免只传一个把另两个覆盖成默认。
|
|
if req.TriggerRunMode != nil || req.TriggerMergeMode != nil || req.TriggerMaxParallel != nil {
|
|
runMode, mergeMode, maxPar := a.TriggerRunMode, a.TriggerMergeMode, a.TriggerMaxParallel
|
|
if req.TriggerRunMode != nil {
|
|
runMode = *req.TriggerRunMode
|
|
}
|
|
if req.TriggerMergeMode != nil {
|
|
mergeMode = *req.TriggerMergeMode
|
|
}
|
|
if req.TriggerMaxParallel != nil {
|
|
maxPar = *req.TriggerMaxParallel
|
|
}
|
|
if err := pg.SetAgentTriggerBehavior(a.Key, runMode, mergeMode, maxPar); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
// an agent's LLM binding change also invalidates pinned-task / per-profile caches
|
|
// so their next round re-resolves each agent's bound model.
|
|
if profileChanged {
|
|
s.invalidateProfileAgents()
|
|
}
|
|
// Task bundles capture operational Agent settings (turn/time budgets, tools,
|
|
// web search). Rebuild them even when no global profile is configured.
|
|
s.invalidateTaskAgents()
|
|
// rebuild the live agents so the new max_turns/run_seconds/binding apply without a restart.
|
|
s.cfgMu.Lock()
|
|
cfg, on := s.llmCfg, s.llmOn
|
|
s.cfgMu.Unlock()
|
|
if on {
|
|
_ = s.applyLLM(cfg)
|
|
}
|
|
resp := map[string]any{"ok": true}
|
|
if req.MaxTurns != nil {
|
|
resp["max_turns"] = *req.MaxTurns
|
|
}
|
|
if req.RunSeconds != nil {
|
|
resp["run_seconds"] = *req.RunSeconds
|
|
}
|
|
writeJSON(w, 200, resp)
|
|
}
|
|
|
|
func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
cur, _ := pg.CurrentPrompt(a.ID)
|
|
vars, _ := pg.PromptVars(a.ID)
|
|
vars = withGlobalVars(vars)
|
|
vers, _ := pg.ListPromptVersions(a.ID)
|
|
if vers == nil {
|
|
vers = []db.PromptVersion{}
|
|
}
|
|
mcp, _ := pg.AgentVisible(a.ID, "mcp")
|
|
sk, _ := pg.AgentSkillNames(a.ID)
|
|
if sk == nil {
|
|
sk = []string{}
|
|
}
|
|
// 可选 LLM 配置列表(id/name/model/是否默认),供前端渲染 "默认模型" 下拉;当前绑定见 agent.llm_profile_id。
|
|
profs, _ := pg.ListProfiles()
|
|
llmProfiles := make([]map[string]any, 0, len(profs))
|
|
for _, p := range profs {
|
|
llmProfiles = append(llmProfiles, map[string]any{
|
|
"id": p.ID, "name": p.Name, "model": p.Model, "is_default": p.IsDefault,
|
|
})
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"agent": agentDTO(a), "prompt": cur, "variables": vars, "versions": vers,
|
|
"visibility": map[string]any{"mcp": mcp, "skill": sk},
|
|
"llm_profiles": llmProfiles, // 可绑定的 LLM 配置候选
|
|
|
|
"wrapup_prompt": a.WrapupPrompt, // 已保存的收尾提示词(空=用内置默认)
|
|
"wrapup_default": agent.WrapupDefault(a.Key), // 内置默认(供占位/恢复默认)
|
|
"wrapup_max_turns": a.WrapupMaxTurns, // 已保存的收尾轮数(0=用内置默认)
|
|
"wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key), // 内置默认轮数(供 "0=默认N" 提示)
|
|
// 任务级超时收尾词(仅 worker/planner 有内置默认;task_timeout_supported 供前端决定是否显示该分区)
|
|
"task_timeout_wrapup_supported": agent.TaskTimeoutWrapupDefault(a.Key) != "",
|
|
"task_timeout_wrapup_prompt": a.TaskTimeoutWrapupPrompt,
|
|
"task_timeout_wrapup_default": agent.TaskTimeoutWrapupDefault(a.Key),
|
|
"task_timeout_wrapup_max_turns": a.TaskTimeoutWrapupMaxTurns,
|
|
"task_timeout_wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
|
|
})
|
|
}
|
|
|
|
func (s *Server) pgSavePrompt(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct{ Template, Note string }
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
vars, _ := pg.PromptVars(a.ID)
|
|
if bad := validateTemplate(body.Template, withGlobalVars(vars)); bad != "" {
|
|
writeErr(w, 400, bad)
|
|
return
|
|
}
|
|
ver, err := pg.SavePrompt(a.ID, body.Template, body.Note, "ui")
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"version": ver})
|
|
}
|
|
|
|
// pgResetPrompt restores an agent's prompt body to the in-code built-in default
|
|
// (段 [A]). Only built-in agents have a code default; custom agents have none.
|
|
func (s *Server) pgResetPrompt(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
tmpl, has := agent.BuiltinPromptSeeds()[a.Key]
|
|
if !has {
|
|
writeErr(w, 400, errMgmtNoBuiltinPrompt)
|
|
return
|
|
}
|
|
ver, err := pg.ResetPromptToDefault(a.ID, tmpl)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"version": ver})
|
|
}
|
|
|
|
// pgSaveWrapup stores an agent's wrap-up (settlement) prompt — the text injected on
|
|
// timeout / step-exhaustion. Empty body clears the override → built-in default.
|
|
func (s *Server) pgSaveWrapup(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// MaxTurns is optional (pointer): omit to leave the stored turn budget untouched.
|
|
var body struct {
|
|
Prompt string `json:"prompt"`
|
|
MaxTurns *int `json:"max_turns"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetAgentWrapupPrompt(a.Key, body.Prompt); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if body.MaxTurns != nil {
|
|
n := *body.MaxTurns
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
if err := pg.SetAgentWrapupMaxTurns(a.Key, n); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// pgResetWrapup clears an agent's wrap-up prompt override so the code built-in
|
|
// default is used again.
|
|
func (s *Server) pgResetWrapup(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := pg.SetAgentWrapupPrompt(a.Key, ""); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetAgentWrapupMaxTurns(a.Key, 0); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"wrapup_default": agent.WrapupDefault(a.Key),
|
|
"wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
|
|
})
|
|
}
|
|
|
|
// pgSaveTaskTimeoutWrapup stores an agent's TASK-TIMEOUT wrap-up prompt + turn budget
|
|
// (worker/planner only). Empty prompt / 0 turns clear the override → built-in default.
|
|
func (s *Server) pgSaveTaskTimeoutWrapup(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Prompt string `json:"prompt"`
|
|
MaxTurns *int `json:"max_turns"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
turns := a.TaskTimeoutWrapupMaxTurns // 未传则保留原值
|
|
if body.MaxTurns != nil {
|
|
turns = *body.MaxTurns
|
|
if turns < 0 {
|
|
turns = 0
|
|
}
|
|
}
|
|
if err := pg.SetAgentTaskTimeoutWrapup(a.Key, body.Prompt, turns); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// pgResetTaskTimeoutWrapup clears the task-timeout wrap-up override → built-in default.
|
|
func (s *Server) pgResetTaskTimeoutWrapup(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := pg.SetAgentTaskTimeoutWrapup(a.Key, "", 0); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{
|
|
"ok": true,
|
|
"task_timeout_wrapup_default": agent.TaskTimeoutWrapupDefault(a.Key),
|
|
"task_timeout_wrapup_max_turns_default": agent.WrapupTurnsDefault(a.Key),
|
|
})
|
|
}
|
|
|
|
func (s *Server) pgListPromptVersions(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
vers, err := pg.ListPromptVersions(a.ID)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"versions": vers})
|
|
}
|
|
|
|
func (s *Server) pgPromptVars(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
vars, err := pg.PromptVars(a.ID)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"variables": withGlobalVars(vars)})
|
|
}
|
|
|
|
func (s *Server) pgPreviewPrompt(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
Template string `json:"template"`
|
|
Sample map[string]string `json:"sample"`
|
|
}
|
|
_ = decode(r, &body)
|
|
vars, _ := pg.PromptVars(a.ID)
|
|
if body.Template == "" {
|
|
body.Template, _ = pg.CurrentPrompt(a.ID)
|
|
}
|
|
rendered, err := renderPrompt(body.Template, withGlobalVars(vars), body.Sample)
|
|
if err != nil {
|
|
writeJSON(w, 200, map[string]any{"rendered": "", "error": err.Error()})
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"rendered": rendered})
|
|
}
|
|
|
|
func (s *Server) pgGetAgentVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
mcp, _ := pg.AgentVisible(a.ID, "mcp")
|
|
sk, _ := pg.AgentSkillNames(a.ID)
|
|
if sk == nil {
|
|
sk = []string{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"mcp": mcp, "skill": sk})
|
|
}
|
|
|
|
func (s *Server) pgSetAgentVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg, a, ok := s.agentByKey(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var body struct {
|
|
MCP []int64 `json:"mcp"`
|
|
Skill []string `json:"skill"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetAgentVisibilityKind(a.ID, "mcp", body.MCP); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetAgentSkillVisibility(a.ID, body.Skill); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// ---------- tools (内置工具目录) ----------
|
|
|
|
func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
ts, err := pg.ListTools()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if ts == nil {
|
|
ts = []*db.Tool{}
|
|
}
|
|
// Usage is best-effort decoration. Runtime tool resolution keeps using the plain
|
|
// catalog query, so agent assembly never pays for this aggregate.
|
|
counts, countErr := pg.ToolUsageCounts()
|
|
if countErr != nil {
|
|
log.Printf("[tools] 读取调用统计失败: %v", countErr)
|
|
} else {
|
|
for _, tool := range ts {
|
|
tool.Calls = counts[tool.Key]
|
|
}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"tools": ts})
|
|
}
|
|
|
|
// pgUpdateTool saves the page-editable fields of a built-in tool: description,
|
|
// parameter schema (structure is expected unchanged — only per-param description/
|
|
// default move), agent binding, and enabled. key is taken from the path and never
|
|
// changes (it is welded to the Go handler).
|
|
func (s *Server) pgUpdateTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
key := r.PathValue("key")
|
|
cur, err := pg.GetTool(key)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if cur == nil {
|
|
writeErr(w, 404, errMgmtToolNotFound+key)
|
|
return
|
|
}
|
|
var body struct {
|
|
Description string `json:"description"`
|
|
Schema json.RawMessage `json:"schema"`
|
|
Agents []string `json:"agents"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
agents, _ := json.Marshal(body.Agents)
|
|
if err := pg.UpdateTool(key, body.Description, body.Schema, agents, body.Enabled); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// pgResetTool overwrites a tool row with its code-defined defaults (description,
|
|
// schema, agent binding) and re-enables it — the explicit "恢复默认" action, since
|
|
// startup seeding is first-insert-only and never overwrites edits.
|
|
func (s *Server) pgResetTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
key := r.PathValue("key")
|
|
for _, sd := range agent.BuiltinToolSeeds() {
|
|
if sd.Key != key {
|
|
continue
|
|
}
|
|
schema, _ := json.Marshal(sd.Schema)
|
|
agents, _ := json.Marshal(sd.Agents)
|
|
if err := pg.UpsertToolForce(sd.Key, sd.Desc, schema, agents); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
// orchestration/platform tools (auto agent) — default-bind to "auto".
|
|
autoAgents, _ := json.Marshal([]string{"auto"})
|
|
for _, t := range append(s.orchestrationTools(), s.platformTools()...) {
|
|
if t.Name() != key {
|
|
continue
|
|
}
|
|
schema, _ := json.Marshal(t.InputSchema())
|
|
if err := pg.UpsertToolForce(t.Name(), t.Description(), schema, autoAgents); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
return
|
|
}
|
|
writeErr(w, 404, errMgmtNotBuiltinTool+key)
|
|
}
|
|
|
|
// ---------- mcp ----------
|
|
|
|
func (s *Server) pgListMCP(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
ms, err := pg.ListMCP()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if ms == nil {
|
|
ms = []*db.MCPServer{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"servers": ms})
|
|
}
|
|
|
|
func (s *Server) pgSaveMCP(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var m db.MCPServer
|
|
if err := decode(r, &m); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
isNew := m.ID == 0
|
|
id, err := pg.SaveMCP(&m)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// On initial add, auto-discover + cache the tool list so the UI shows it right
|
|
// away (bounded so a slow/broken server can't hang the request). Skipped on
|
|
// plain updates (e.g. enable toggles) to avoid re-spawning the server each time.
|
|
if isNew && m.Enabled {
|
|
m.ID = id
|
|
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
|
if derr := s.discoverAndCacheMCP(ctx, &m); derr != nil {
|
|
log.Printf("[mcp] %s 添加后工具发现失败: %v", m.Name, derr)
|
|
}
|
|
cancel()
|
|
}
|
|
writeJSON(w, 200, map[string]any{"id": id})
|
|
}
|
|
|
|
func (s *Server) pgDeleteMCP(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, _ := pathInt(r, "id")
|
|
if err := pg.DeleteMCP(id); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": id})
|
|
}
|
|
|
|
// pgRefreshMCP re-discovers one MCP's tools on demand and re-caches them, so a
|
|
// config change or an earlier discovery failure can be fixed without a restart.
|
|
func (s *Server) pgRefreshMCP(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, _ := pathInt(r, "id")
|
|
all, err := pg.ListMCP()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
var target *db.MCPServer
|
|
for _, m := range all {
|
|
if m.ID == id {
|
|
target = m
|
|
break
|
|
}
|
|
}
|
|
if target == nil {
|
|
writeErr(w, 404, errMgmtMCPNotFound)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
|
defer cancel()
|
|
if err := s.discoverAndCacheMCP(ctx, target); err != nil {
|
|
writeErr(w, 502, errMgmtToolDiscoverFail+err.Error())
|
|
return
|
|
}
|
|
tools, _ := pg.MCPToolsDetailed(id)
|
|
writeJSON(w, 200, map[string]any{"tools": tools})
|
|
}
|
|
|
|
// pgMCPTools returns one MCP's cached tools (name + description) for the detail UI.
|
|
func (s *Server) pgMCPTools(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, _ := pathInt(r, "id")
|
|
tools, err := pg.MCPToolsDetailed(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if tools == nil {
|
|
tools = []db.MCPTool{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"tools": tools})
|
|
}
|
|
|
|
// ---------- skills (文件系统) ----------
|
|
|
|
type skillFileNode struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
License string `json:"license,omitempty"`
|
|
Compatibility string `json:"compatibility,omitempty"`
|
|
MCPs []string `json:"mcps,omitempty"`
|
|
Files []string `json:"files"`
|
|
// Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that
|
|
// was never invoked — the ledger only has rows for skills agents actually loaded.
|
|
Calls int `json:"calls"`
|
|
Tasks int `json:"tasks"`
|
|
Agents []string `json:"usage_agents"`
|
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
|
}
|
|
|
|
func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
|
|
_ = os.MkdirAll(s.skillDir, 0o755)
|
|
allReg, _ := skill.LoadDir(s.skillDir)
|
|
metaByDir := map[string]skill.Skill{}
|
|
if allReg != nil {
|
|
for _, sk := range allReg.List() {
|
|
if sk.Dir != "" {
|
|
metaByDir[filepath.Base(sk.Dir)] = sk
|
|
}
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(s.skillDir)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// Usage is best-effort decoration: a ledger read failure leaves the counts at
|
|
// zero rather than failing the skill list itself.
|
|
statBySkill := map[string]db.SkillStat{}
|
|
if s.m.pg != nil {
|
|
stats, err := s.m.pg.SkillStats()
|
|
if err != nil {
|
|
log.Printf("[skills] 读取调用统计失败: %v", err)
|
|
}
|
|
for _, st := range stats {
|
|
statBySkill[st.Skill] = st
|
|
}
|
|
}
|
|
nodes := []skillFileNode{}
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
dirName := e.Name()
|
|
node := skillFileNode{Name: dirName, Agents: []string{}}
|
|
if st, ok := statBySkill[dirName]; ok {
|
|
node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed
|
|
}
|
|
if meta, ok := metaByDir[dirName]; ok {
|
|
node.Description = meta.Description
|
|
node.License = meta.License
|
|
node.Compatibility = meta.Compatibility
|
|
node.MCPs = meta.MCPs
|
|
}
|
|
node.Files, _ = walkSkillFiles(filepath.Join(s.skillDir, dirName))
|
|
if node.Files == nil {
|
|
node.Files = []string{}
|
|
}
|
|
nodes = append(nodes, node)
|
|
}
|
|
writeJSON(w, 200, map[string]any{"skills": nodes})
|
|
}
|
|
|
|
// fsSkillUsage returns one skill's recent invocations, newest first (detail panel).
|
|
func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, errMgmtSkillNameInvalid)
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
calls, err := pg.RecentSkillCalls(name, limit)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"calls": calls})
|
|
}
|
|
|
|
// fsMissingSkills lists skill names agents asked for that do not exist — the gap
|
|
// list, i.e. which procedures are worth writing next.
|
|
func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
missing, err := pg.MissingSkillStats(limit)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"missing": missing})
|
|
}
|
|
|
|
// cleanStrs trims each string and drops empties.
|
|
func cleanStrs(in []string) []string {
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) fsCreateSkill(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"` // required per agentskills.io spec
|
|
License string `json:"license"` // optional
|
|
Compatibility string `json:"compatibility"` // optional
|
|
MCPs []string `json:"mcps"` // optional; MCP servers this skill unlocks
|
|
Instructions string `json:"instructions"` // optional; scaffolded if empty
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if !validSkillName(body.Name) {
|
|
writeErr(w, 400, "skill name must be 1-64 lowercase alphanumeric/hyphen characters, not starting/ending/doubling hyphens")
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Description) == "" {
|
|
writeErr(w, 400, "description is required")
|
|
return
|
|
}
|
|
skillPath := filepath.Join(s.skillDir, body.Name)
|
|
if _, err := os.Stat(skillPath); err == nil {
|
|
writeErr(w, 409, "skill already exists")
|
|
return
|
|
}
|
|
if err := os.MkdirAll(skillPath, 0o755); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// Build a spec-compliant SKILL.md (agentskills.io format):
|
|
// YAML frontmatter: name (required), description (required),
|
|
// license, compatibility (optional)
|
|
// Markdown body: step-by-step instructions
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
fmt.Fprintf(&sb, "name: %s\n", body.Name)
|
|
fmt.Fprintf(&sb, "description: %s\n", body.Description)
|
|
if body.License != "" {
|
|
fmt.Fprintf(&sb, "license: %s\n", body.License)
|
|
}
|
|
if body.Compatibility != "" {
|
|
fmt.Fprintf(&sb, "compatibility: %s\n", body.Compatibility)
|
|
}
|
|
// mcps: MCP servers this skill unlocks on load (skill-gated deferred tools).
|
|
if mcps := cleanStrs(body.MCPs); len(mcps) > 0 {
|
|
fmt.Fprintf(&sb, "mcps: %s\n", strings.Join(mcps, ", "))
|
|
}
|
|
sb.WriteString("---\n")
|
|
if strings.TrimSpace(body.Instructions) != "" {
|
|
sb.WriteString(body.Instructions)
|
|
} else {
|
|
// scaffold a minimal Markdown body so the file is immediately useful
|
|
fmt.Fprintf(&sb, "## %s\n\n", body.Name)
|
|
sb.WriteString("<!-- Describe step-by-step instructions in Markdown. -->\n\n")
|
|
sb.WriteString("1. \n2. \n3. \n")
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skillPath, "SKILL.md"), []byte(sb.String()), 0o644); err != nil {
|
|
_ = os.RemoveAll(skillPath)
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 201, map[string]any{"name": body.Name})
|
|
}
|
|
|
|
// fsUpdateSkillMeta rewrites the SKILL.md frontmatter fields that are safe to
|
|
// change without touching the instruction body: mcps, license, compatibility,
|
|
// description. Only fields present in the request body are updated; omitted
|
|
// fields are left as-is (the whole frontmatter is reconstructed from the
|
|
// parsed values, so the write is a clean replace, not a line-patch).
|
|
func (s *Server) fsUpdateSkillMeta(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
var body struct {
|
|
MCPs *[]string `json:"mcps"`
|
|
Description *string `json:"description"`
|
|
License *string `json:"license"`
|
|
Compatibility *string `json:"compatibility"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
skillMD := filepath.Join(s.skillDir, name, "SKILL.md")
|
|
raw, err := os.ReadFile(skillMD)
|
|
if err != nil {
|
|
writeErr(w, 404, "skill not found")
|
|
return
|
|
}
|
|
updated, err := rewriteSkillFrontmatter(raw, body.MCPs, body.Description, body.License, body.Compatibility)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := os.WriteFile(skillMD, updated, 0o644); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// rewriteSkillFrontmatter parses the SKILL.md YAML frontmatter and replaces the
|
|
// fields given as non-nil pointers. The instruction body (after the closing ---) is
|
|
// preserved verbatim. Returns an error if the file has no recognisable frontmatter.
|
|
func rewriteSkillFrontmatter(content []byte, mcps *[]string, description, license, compatibility *string) ([]byte, error) {
|
|
lines := strings.Split(string(content), "\n")
|
|
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
|
|
return nil, fmt.Errorf("SKILL.md has no YAML frontmatter")
|
|
}
|
|
fmEnd := -1
|
|
for i := 1; i < len(lines); i++ {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
fmEnd = i
|
|
break
|
|
}
|
|
}
|
|
if fmEnd < 0 {
|
|
return nil, fmt.Errorf("SKILL.md frontmatter is not closed")
|
|
}
|
|
// Collect existing key → value from frontmatter (preserve unknown keys)
|
|
type kv struct{ k, v string }
|
|
var pairs []kv
|
|
for _, l := range lines[1:fmEnd] {
|
|
if idx := strings.IndexByte(l, ':'); idx >= 0 {
|
|
pairs = append(pairs, kv{strings.TrimSpace(l[:idx]), strings.TrimSpace(l[idx+1:])})
|
|
} else if strings.TrimSpace(l) != "" {
|
|
pairs = append(pairs, kv{"", l}) // preserve non-key lines verbatim
|
|
}
|
|
}
|
|
// Apply updates (nil pointer = no change)
|
|
applyStr := func(key string, val *string) {
|
|
if val == nil {
|
|
return
|
|
}
|
|
for i, p := range pairs {
|
|
if p.k == key {
|
|
pairs[i].v = strings.TrimSpace(*val)
|
|
return
|
|
}
|
|
}
|
|
pairs = append(pairs, kv{key, strings.TrimSpace(*val)})
|
|
}
|
|
applyStr("description", description)
|
|
applyStr("license", license)
|
|
applyStr("compatibility", compatibility)
|
|
if mcps != nil {
|
|
cleaned := cleanStrs(*mcps)
|
|
// Remove existing mcps line
|
|
filtered := pairs[:0]
|
|
for _, p := range pairs {
|
|
if p.k != "mcps" {
|
|
filtered = append(filtered, p)
|
|
}
|
|
}
|
|
pairs = filtered
|
|
if len(cleaned) > 0 {
|
|
pairs = append(pairs, kv{"mcps", strings.Join(cleaned, ", ")})
|
|
}
|
|
}
|
|
// Reconstruct
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
for _, p := range pairs {
|
|
if p.k == "" {
|
|
sb.WriteString(p.v)
|
|
} else {
|
|
fmt.Fprintf(&sb, "%s: %s", p.k, p.v)
|
|
}
|
|
sb.WriteByte('\n')
|
|
}
|
|
sb.WriteString("---\n")
|
|
// Body (lines after closing ---)
|
|
if fmEnd+1 < len(lines) {
|
|
sb.WriteString(strings.Join(lines[fmEnd+1:], "\n"))
|
|
}
|
|
return []byte(sb.String()), nil
|
|
}
|
|
|
|
// zip-upload safety caps (defeat zip bombs / runaway archives).
|
|
const (
|
|
maxSkillZipBytes = 20 << 20 // 20MB compressed request body
|
|
maxSkillTotalBytes = 100 << 20 // 100MB total uncompressed
|
|
maxSkillFileBytes = 20 << 20 // 20MB per extracted file
|
|
maxSkillEntries = 4000 // max files in the archive
|
|
)
|
|
|
|
// skillNameFromFrontmatter extracts the `name:` value from a SKILL.md's YAML
|
|
// frontmatter (the block between the first two `---` lines). "" if absent.
|
|
func skillNameFromFrontmatter(md []byte) string {
|
|
lines := strings.Split(string(md), "\n")
|
|
inFM := false
|
|
for _, ln := range lines {
|
|
t := strings.TrimSpace(ln)
|
|
if t == "---" {
|
|
if !inFM {
|
|
inFM = true
|
|
continue
|
|
}
|
|
break // end of frontmatter
|
|
}
|
|
if inFM && strings.HasPrefix(t, "name:") {
|
|
v := strings.TrimSpace(strings.TrimPrefix(t, "name:"))
|
|
return strings.Trim(v, `"'`) // name: "中文技能" 也认
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fsUploadSkill installs a skill from an uploaded .zip. The archive must contain a
|
|
// SKILL.md (at the root or under a single top-level dir); the skill name is taken
|
|
// from that file's `name:` frontmatter (falling back to the top dir / zip name).
|
|
// Zip-slip is defeated by validating every entry path with skillRelPath, and a set
|
|
// of size/count caps guard against zip bombs. POST ?overwrite=true replaces an
|
|
// existing skill of the same name.
|
|
func (s *Server) fsUploadSkill(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxSkillZipBytes)
|
|
file, hdr, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeErr(w, 400, errMgmtSkillNoFile)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
buf, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
zr, err := newSkillZipReader(buf)
|
|
if err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
// entries carry UTF-8-decoded names (GBK 包也能读) and exclude archiver junk.
|
|
entriesAll := skillZipEntries(zr)
|
|
if err := checkSkillZipMethods(entriesAll); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
|
|
// locate the shallowest SKILL.md → its directory is the skill root inside the zip.
|
|
var skillMD *skillZipEntry
|
|
for i := range entriesAll {
|
|
e := &entriesAll[i]
|
|
if path.Base(e.name) != "SKILL.md" {
|
|
continue
|
|
}
|
|
if skillMD == nil || strings.Count(e.name, "/") < strings.Count(skillMD.name, "/") {
|
|
skillMD = e
|
|
}
|
|
}
|
|
if skillMD == nil {
|
|
writeErr(w, 400, errMgmtSkillNoMDInZip)
|
|
return
|
|
}
|
|
root := path.Dir(skillMD.name) // "." when SKILL.md is at the zip root
|
|
prefix := ""
|
|
if root != "." {
|
|
prefix = root + "/"
|
|
}
|
|
|
|
// derive + validate the skill name from the SKILL.md frontmatter.
|
|
md, err := readZipEntry(skillMD.f)
|
|
if err != nil {
|
|
writeErr(w, 400, errMgmtSkillReadMDFail+err.Error())
|
|
return
|
|
}
|
|
name := skillNameFromFrontmatter(md)
|
|
if name == "" && prefix != "" {
|
|
name = path.Base(strings.TrimSuffix(prefix, "/"))
|
|
}
|
|
if name == "" {
|
|
base := path.Base(filepath.ToSlash(hdr.Filename))
|
|
name = strings.TrimSuffix(base, path.Ext(base))
|
|
}
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, errMgmtSkillNamePre+name+errMgmtSkillNamePost)
|
|
return
|
|
}
|
|
|
|
skillPath := filepath.Join(s.skillDir, name)
|
|
overwrite := r.URL.Query().Get("overwrite") == "true"
|
|
if _, err := os.Stat(skillPath); err == nil && !overwrite {
|
|
writeErr(w, 409, errMgmtSkillExistsPre+name+errMgmtSkillExistsPost)
|
|
return
|
|
}
|
|
|
|
// extract into a temp dir first, then atomically swap in — a bad entry aborts
|
|
// the whole upload without leaving a half-written skill.
|
|
_ = os.MkdirAll(s.skillDir, 0o755)
|
|
tmp, err := os.MkdirTemp(s.skillDir, ".upload-*")
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
defer os.RemoveAll(tmp) // no-op after a successful rename
|
|
|
|
var total int64
|
|
entries := 0
|
|
for _, e := range entriesAll {
|
|
f := e.f
|
|
// only files under the skill root; skip anything outside it.
|
|
if prefix != "" && !strings.HasPrefix(e.name, prefix) {
|
|
continue
|
|
}
|
|
rel := strings.TrimPrefix(e.name, prefix)
|
|
if rel == "" {
|
|
continue
|
|
}
|
|
clean, msg := skillRelPath(rel)
|
|
if msg != "" {
|
|
writeErr(w, 400, errMgmtSkillZipBadPath+e.name+": "+msg)
|
|
return
|
|
}
|
|
if entries++; entries > maxSkillEntries {
|
|
writeErr(w, 400, errMgmtSkillZipTooMany)
|
|
return
|
|
}
|
|
if f.UncompressedSize64 > maxSkillFileBytes {
|
|
writeErr(w, 400, errMgmtSkillFileTooLarge+rel)
|
|
return
|
|
}
|
|
dst := filepath.Join(tmp, clean)
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
rc.Close()
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
n, err := io.Copy(out, io.LimitReader(rc, maxSkillFileBytes+1))
|
|
out.Close()
|
|
rc.Close()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
total += n
|
|
if total > maxSkillTotalBytes {
|
|
writeErr(w, 400, errMgmtSkillZipTooLarge)
|
|
return
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmp, "SKILL.md")); err != nil {
|
|
writeErr(w, 400, errMgmtSkillNoMDAfter)
|
|
return
|
|
}
|
|
|
|
if overwrite {
|
|
_ = os.RemoveAll(skillPath)
|
|
}
|
|
if err := os.Rename(tmp, skillPath); err != nil {
|
|
writeErr(w, 500, errMgmtSkillInstallFail+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 201, map[string]any{"name": name, "files": entries})
|
|
}
|
|
|
|
// readZipEntry reads a single entry's bytes (capped). Takes the *zip.File rather than
|
|
// a name because zr.Open rejects names that aren't valid UTF-8 (GBK-named archives).
|
|
func readZipEntry(f *zip.File) ([]byte, error) {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rc.Close()
|
|
return io.ReadAll(io.LimitReader(rc, maxSkillFileBytes))
|
|
}
|
|
|
|
func (s *Server) fsDeleteSkill(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
if err := pg.DeleteSkillVisibility(name); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(s.skillDir, name)); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": name})
|
|
}
|
|
|
|
// skillPathBlocked lists the ASCII characters a skill-relative path may not contain.
|
|
// '\' is a Windows separator; '%' keeps double-URL-encoding tricks from surviving
|
|
// (Go's net/http URL-decodes PathValue once — %2F→/ %2e→. — so an attacker sending
|
|
// %252e%252e arrives here as the literal "%2e%2e" and is rejected on the '%'); '#'
|
|
// and '?' would truncate the path when it travels back through a URL; the rest are
|
|
// reserved on Windows filesystems.
|
|
const skillPathBlocked = `\%#?*:"<>|`
|
|
|
|
// skillPathRune reports whether r may appear in a client-supplied skill path.
|
|
// It is a blacklist over Unicode rather than an ASCII whitelist so that 中文 (and any
|
|
// other script) file names work, while everything that makes path validation hard is
|
|
// still refused: control/format characters, look-alike whitespace, separators.
|
|
func skillPathRune(r rune) bool {
|
|
switch {
|
|
case r < 0x20, r == 0x7f, r == utf8.RuneError:
|
|
return false // NUL & control characters, invalid UTF-8
|
|
case strings.ContainsRune(skillPathBlocked, r):
|
|
return false
|
|
case unicode.Is(unicode.Cf, r), unicode.Is(unicode.Co, r), unicode.Is(unicode.Cs, r):
|
|
return false // zero-width joiners, bidi overrides (RLO 文件名伪装), private use
|
|
case r != ' ' && unicode.IsSpace(r):
|
|
return false // NBSP / 全角空格 之类:看着是空格,其实不是
|
|
}
|
|
return true
|
|
}
|
|
|
|
// maxSkillPathLen caps a relative path so a pathological name can't reach the syscall.
|
|
const maxSkillPathLen = 512
|
|
|
|
// skillRelPath validates a relative file path supplied by the client.
|
|
// Returns the cleaned path and an empty error string on success.
|
|
// Validation order matters: character check first (before Clean) so encoding tricks
|
|
// can't survive normalization.
|
|
func skillRelPath(file string) (string, string) {
|
|
// 1. Character check — before normalization. Defeats null bytes, backslash,
|
|
// '%', control characters and unicode look-alikes; CJK names pass through.
|
|
if file == "" || len(file) > maxSkillPathLen {
|
|
return "", "invalid path: empty or too long"
|
|
}
|
|
if !utf8.ValidString(file) {
|
|
return "", "invalid path: not valid UTF-8"
|
|
}
|
|
for _, r := range file {
|
|
if !skillPathRune(r) {
|
|
return "", "invalid path: illegal character " + strconv.QuoteRune(r)
|
|
}
|
|
}
|
|
// 2. Reject ".." explicitly. With the whitelist above, encoding bypass is already
|
|
// impossible, but we keep this check so the intent is obvious to reviewers.
|
|
if strings.Contains(file, "..") {
|
|
return "", "invalid path: '..' not allowed"
|
|
}
|
|
// 3. Reject leading slash and empty segments (double slash).
|
|
// Leading slash would survive filepath.Clean as an absolute path.
|
|
if strings.HasPrefix(file, "/") || strings.Contains(file, "//") {
|
|
return "", "invalid path: must be relative with no empty segments"
|
|
}
|
|
// 4. Normalize and final safety re-check after Clean.
|
|
// filepath.Clean removes redundant separators and resolves single dots.
|
|
clean := filepath.Clean(file)
|
|
if clean == "." || filepath.IsAbs(clean) || strings.HasPrefix(clean, "..") {
|
|
return "", "invalid path"
|
|
}
|
|
return clean, ""
|
|
}
|
|
|
|
// walkSkillFiles returns all files under root (relative to root), sorted,
|
|
// including files in subdirectories (scripts/, references/, assets/, etc.).
|
|
// walkSkillFiles returns all entries under root relative to root.
|
|
// Directories are included with a trailing "/" so the frontend can distinguish
|
|
// them from files and render empty folders in the tree.
|
|
func walkSkillFiles(root string) ([]string, error) {
|
|
var entries []string
|
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil // skip unreadable entries
|
|
}
|
|
rel, relErr := filepath.Rel(root, path)
|
|
if relErr != nil || rel == "." {
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
entries = append(entries, rel+"/")
|
|
} else {
|
|
entries = append(entries, rel)
|
|
}
|
|
return nil
|
|
})
|
|
return entries, err
|
|
}
|
|
|
|
func (s *Server) fsListFiles(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
dirPath := filepath.Join(s.skillDir, name)
|
|
if _, err := os.Stat(dirPath); os.IsNotExist(err) {
|
|
writeErr(w, 404, "skill not found")
|
|
return
|
|
}
|
|
files, err := walkSkillFiles(dirPath)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if files == nil {
|
|
files = []string{}
|
|
}
|
|
writeJSON(w, 200, map[string]any{"files": files})
|
|
}
|
|
|
|
func (s *Server) fsReadFile(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
file, errMsg := skillRelPath(r.PathValue("file"))
|
|
if errMsg != "" {
|
|
writeErr(w, 400, errMsg)
|
|
return
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(s.skillDir, name, file))
|
|
if os.IsNotExist(err) {
|
|
writeErr(w, 404, "file not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"content": string(data), "file": file})
|
|
}
|
|
|
|
func (s *Server) fsWriteFile(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
file, errMsg := skillRelPath(r.PathValue("file"))
|
|
if errMsg != "" {
|
|
writeErr(w, 400, errMsg)
|
|
return
|
|
}
|
|
var body struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
skillPath := filepath.Join(s.skillDir, name)
|
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
|
writeErr(w, 404, "skill not found")
|
|
return
|
|
}
|
|
fullPath := filepath.Join(skillPath, file)
|
|
// create parent subdirectory if needed (e.g. scripts/, references/, assets/)
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if err := os.WriteFile(fullPath, []byte(body.Content), 0o644); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) fsCreateDir(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
var body struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
dir, errMsg := skillRelPath(body.Path)
|
|
if errMsg != "" {
|
|
writeErr(w, 400, errMsg)
|
|
return
|
|
}
|
|
skillPath := filepath.Join(s.skillDir, name)
|
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
|
writeErr(w, 404, "skill not found")
|
|
return
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(skillPath, dir), 0o755); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 201, map[string]any{"dir": dir})
|
|
}
|
|
|
|
func (s *Server) fsDeletePath(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
if !validSkillName(name) {
|
|
writeErr(w, 400, "invalid skill name")
|
|
return
|
|
}
|
|
file, errMsg := skillRelPath(r.PathValue("file"))
|
|
if errMsg != "" {
|
|
writeErr(w, 400, errMsg)
|
|
return
|
|
}
|
|
fullPath := filepath.Join(s.skillDir, name, file)
|
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
writeErr(w, 404, "not found")
|
|
return
|
|
}
|
|
if err := os.RemoveAll(fullPath); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": file})
|
|
}
|
|
|
|
// ---------- skill visibility ----------
|
|
|
|
func (s *Server) pgSkillVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
agents, err := pg.SkillAgents(r.PathValue("name"))
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"agents": idStrings(agents)})
|
|
}
|
|
|
|
func (s *Server) pgToggleSkillVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var body struct {
|
|
AgentID int64 `json:"agent_id,string"`
|
|
SkillName string `json:"skill_name"`
|
|
Visible bool `json:"visible"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.ToggleSkillVisibility(body.AgentID, body.SkillName, body.Visible); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// ---------- visibility (MCP resource side + toggle) ----------
|
|
|
|
func (s *Server) pgResourceVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, _ := pathInt(r, "id")
|
|
agents, err := pg.ResourceAgents(r.PathValue("kind"), id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"agents": idStrings(agents)})
|
|
}
|
|
|
|
func (s *Server) pgToggleVisibility(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var body struct {
|
|
AgentID int64 `json:"agent_id,string"`
|
|
Kind string `json:"kind"`
|
|
ResourceID int64 `json:"resource_id"`
|
|
Visible bool `json:"visible"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.ToggleVisibility(body.AgentID, body.Kind, body.ResourceID, body.Visible); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// ---------- llm profiles ----------
|
|
|
|
func (s *Server) pgListProfiles(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
ps, err := pg.ListProfiles()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"profiles": llmProfileDTOs(ps)})
|
|
}
|
|
|
|
func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
// APIKey has json:"-" on db.LLMProfile so it is never leaked to the UI on
|
|
// read; accept it here via a sibling field for create/update. Streaming is a
|
|
// *bool shadowing the embedded json:"streaming": an absent field must default
|
|
// to streaming (true), which a plain bool zero value (false = non-streaming)
|
|
// would get wrong — legacy/partial clients that never send it stay streaming.
|
|
var body struct {
|
|
db.LLMProfile
|
|
APIKey string `json:"api_key"`
|
|
Streaming *bool `json:"streaming"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
p := body.LLMProfile
|
|
p.APIKey = body.APIKey
|
|
p.Streaming = body.Streaming == nil || *body.Streaming
|
|
// 输出上限:负数无意义,归零(= 不发送该字段)。字段名开关只有 Chat Completions
|
|
// 用得上——anthropic 与 openai-responses 各自定死了字段名,存下来只会误导后续读者,
|
|
// 故非 openai 格式一律清空。未知取值同样清空,避免把 DB CHECK 的报错甩给用户。
|
|
if p.MaxTokens < 0 {
|
|
p.MaxTokens = 0
|
|
}
|
|
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
|
|
p.MaxTokensField = ""
|
|
}
|
|
id, err := pg.SaveProfile(&p)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
// Editing a profile rebuilds any task pinned to it on its next round. Reapply
|
|
// the active profile too: the global fallback and explicit task chains must
|
|
// repopulate the same provider-cache entry and therefore share one limiter.
|
|
s.invalidateProfileAgents()
|
|
// Hot-apply. Not just when the ACTIVE profile changed: with failover on, any
|
|
// profile's key/model/priority/pool_exclude edit reshapes the chain, so the
|
|
// engine's provider has to be rebuilt either way.
|
|
s.reapplyActiveProfile()
|
|
writeJSON(w, 200, map[string]any{"id": id})
|
|
}
|
|
|
|
// pgGetLLMRetryPolicy 返回全局重试策略(五层各自的次数+间隔)。未配置过 → 全零,
|
|
// 前端把零显示成「默认」。
|
|
func (s *Server) pgGetLLMRetryPolicy(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
writeJSON(w, 200, pg.LLMRetryPolicy())
|
|
}
|
|
|
|
// pgSaveLLMRetryPolicy 保存全局重试策略。三个「跟着端点走」的层(建连/空响应/同
|
|
// provider 安全窗口)是 provider 的构建参数或调用参数,改完必须让缓存里的 provider
|
|
// 重建;熔断参数则直接推给进程级 Registry。
|
|
func (s *Server) pgSaveLLMRetryPolicy(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var pol db.LLMRetryPolicy
|
|
if err := decode(r, &pol); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetLLMRetryPolicy(pol); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.applyRetryPolicy()
|
|
s.invalidateProfileAgents()
|
|
s.reapplyActiveProfile()
|
|
writeJSON(w, 200, pg.LLMRetryPolicy())
|
|
}
|
|
|
|
func (s *Server) pgDeleteProfile(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
id, _ := pathInt(r, "id")
|
|
if err := pg.DeleteProfileContext(r.Context(), id); err != nil {
|
|
switch {
|
|
case errors.Is(err, db.ErrActiveLLMProfileDelete):
|
|
writeErr(w, 409, errMgmtLLMActiveDelete)
|
|
case errors.Is(err, db.ErrLLMProfileReferencesChanged):
|
|
writeErr(w, 409, errMgmtLLMRefChanged)
|
|
case errors.Is(err, context.DeadlineExceeded):
|
|
writeErr(w, 409, errMgmtLLMRefTimeout)
|
|
case errors.Is(err, db.ErrLLMProfileNotFound):
|
|
writeErr(w, 404, err.Error())
|
|
default:
|
|
writeErr(w, 500, err.Error())
|
|
}
|
|
return
|
|
}
|
|
s.invalidateProfileAgents() // drop cached agents for the removed profile
|
|
s.llmHealth.Reset(id) // its breaker state is meaningless now (row is FK-cascaded away)
|
|
s.restoreTasksAfterProfileDelete(pg)
|
|
// Cache invalidation also removed the active profile's shared provider entry.
|
|
// Reapply after task-state sync so the wake-up observes the post-delete chain.
|
|
s.reapplyActiveProfile()
|
|
writeJSON(w, 200, map[string]any{"deleted": id})
|
|
}
|
|
|
|
func (s *Server) restoreTasksAfterProfileDelete(pg *db.DB) {
|
|
for _, task := range s.m.List() {
|
|
if taskID, err := strconv.ParseInt(task.ID, 10, 64); err == nil {
|
|
if pt, err := pg.GetTask(taskID); err == nil && pt != nil {
|
|
s.syncTaskLLMState(pt)
|
|
// Deleting the active entry may advance the task to a ready successor,
|
|
// or clear the explicit chain and restore its Agent/global fallback.
|
|
// Resume only intents that were blocked by the exhausted chain; a
|
|
// paused task remains paused and terminal tasks remain immutable.
|
|
if !isTerminalStatus(task.lifecycleSnapshot().Status) && s.taskRuntimeAvailable(task, "planner", "worker") {
|
|
if _, reopenErr := task.Store.ReopenIntentsByBlockedReason(db.IntentBlockedLLMQuota); reopenErr != nil {
|
|
log.Printf("[llm-profile] task %s reopen quota-blocked intents after profile delete: %v", task.ID, reopenErr)
|
|
}
|
|
}
|
|
task.Notify()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var body struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if err := pg.SetActiveProfile(body.ID); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
s.invalidateProfileAgents() // active change may affect pinned-task fallbacks
|
|
s.reapplyActiveProfile() // switch the running engine to the newly activated profile
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// pgLLMPoolStatus reports the failover ("轮询") switches, the resolved chain order
|
|
// and every profile's circuit-breaker state — what the LLM page renders as the
|
|
// "轮询顺序" strip and the per-card health badges.
|
|
func (s *Server) pgLLMPoolStatus(w http.ResponseWriter, r *http.Request) {
|
|
if s.pg(w) == nil {
|
|
return
|
|
}
|
|
writeJSON(w, 200, s.llmPoolStatus())
|
|
}
|
|
|
|
// pgLLMPoolReset clears a tripped profile's circuit breaker so the next call
|
|
// tries it again immediately ("立即恢复"). id=0 clears every profile.
|
|
func (s *Server) pgLLMPoolReset(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var body struct {
|
|
ID int64 `json:"id"` // 0 / omitted = all
|
|
}
|
|
if err := decode(r, &body); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if body.ID > 0 {
|
|
s.llmHealth.Reset(body.ID)
|
|
} else {
|
|
for id := range s.llmHealth.Snapshot() {
|
|
s.llmHealth.Reset(id)
|
|
}
|
|
}
|
|
writeJSON(w, 200, s.llmPoolStatus())
|
|
}
|
|
|
|
// pgListModels fetches available models from the provider's API endpoint.
|
|
// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for
|
|
// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on
|
|
// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root.
|
|
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Provider string `json:"provider"` // "openai" | "anthropic"
|
|
BaseURL string `json:"base_url"`
|
|
APIKey string `json:"api_key"`
|
|
Proxy string `json:"proxy"`
|
|
ProfileID *int64 `json:"profile_id"` // fallback: use stored key from this profile
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
// Resolve API key: form input > profile stored key.
|
|
apiKey := strings.TrimSpace(req.APIKey)
|
|
if apiKey == "" && req.ProfileID != nil {
|
|
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
|
apiKey = p.APIKey
|
|
}
|
|
}
|
|
if apiKey == "" {
|
|
writeJSON(w, 200, map[string]any{"ok": false, "error": errMgmtNoAPIKey})
|
|
return
|
|
}
|
|
|
|
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
|
provider := strings.TrimSpace(req.Provider)
|
|
|
|
// Candidate endpoints to try in order. Some Anthropic-compatible providers
|
|
// (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose
|
|
// the model list only on their OpenAI-format path — so for anthropic we fall
|
|
// back to the OpenAI endpoint at the stripped root.
|
|
type candidate struct {
|
|
url string
|
|
hdr http.Header
|
|
}
|
|
bearerHdr := func() http.Header {
|
|
h := http.Header{}
|
|
h.Set("Authorization", "Bearer "+apiKey)
|
|
return h
|
|
}
|
|
anthropicHdr := func() http.Header {
|
|
h := http.Header{}
|
|
h.Set("x-api-key", apiKey)
|
|
h.Set("anthropic-version", "2023-06-01")
|
|
return h
|
|
}
|
|
|
|
var candidates []candidate
|
|
switch provider {
|
|
case "openai", "openai-responses":
|
|
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
|
|
// endpoint URL of either format.
|
|
if baseURL == "" {
|
|
baseURL = "https://api.openai.com/v1"
|
|
}
|
|
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
|
|
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
|
|
default: // anthropic
|
|
if baseURL == "" {
|
|
baseURL = "https://api.anthropic.com"
|
|
}
|
|
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/v1/messages"), "/")
|
|
candidates = append(candidates, candidate{b + "/v1/models", anthropicHdr()})
|
|
// Fallback for Anthropic-compatible third parties whose model list lives on
|
|
// the OpenAI path: strip a trailing /anthropic and try the OpenAI endpoint.
|
|
if root := strings.TrimRight(strings.TrimSuffix(b, "/anthropic"), "/"); root != b {
|
|
candidates = append(candidates,
|
|
candidate{root + "/models", bearerHdr()},
|
|
candidate{root + "/v1/models", bearerHdr()},
|
|
)
|
|
}
|
|
}
|
|
|
|
// Build HTTP client with optional proxy.
|
|
transport := &http.Transport{}
|
|
if p := strings.TrimSpace(req.Proxy); p != "" {
|
|
if pu, err := url.Parse(p); err == nil {
|
|
transport.Proxy = http.ProxyURL(pu)
|
|
}
|
|
}
|
|
client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
|
|
|
|
// Try each candidate; return the first that yields a non-empty model list.
|
|
var lastErr string
|
|
emptyOK := false
|
|
for _, c := range candidates {
|
|
httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, c.url, nil)
|
|
if err != nil {
|
|
lastErr = errMgmtBuildReqFail + err.Error()
|
|
continue
|
|
}
|
|
httpReq.Header = c.hdr
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
lastErr = errMgmtReqFail + err.Error()
|
|
continue
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
lastErr = fmt.Sprintf(errMgmtAPIReturned, resp.StatusCode, string(body[:min(len(body), 512)]))
|
|
continue
|
|
}
|
|
// Both OpenAI and Anthropic return {"data": [{"id": "..."},...]}.
|
|
var parsed struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
lastErr = errMgmtParseRespFail + err.Error()
|
|
continue
|
|
}
|
|
models := make([]string, 0, len(parsed.Data))
|
|
for _, m := range parsed.Data {
|
|
if m.ID != "" {
|
|
models = append(models, m.ID)
|
|
}
|
|
}
|
|
if len(models) > 0 {
|
|
writeJSON(w, 200, map[string]any{"ok": true, "models": models})
|
|
return
|
|
}
|
|
emptyOK = true // 200 but no model ids — keep trying other candidates
|
|
}
|
|
if emptyOK {
|
|
writeJSON(w, 200, map[string]any{"ok": true, "models": []string{}})
|
|
return
|
|
}
|
|
if lastErr == "" {
|
|
lastErr = errMgmtNoModelList
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": false, "error": lastErr})
|
|
}
|
|
|
|
// --- prompt template helpers (Go text/template + catalog 白名单) ---
|
|
|
|
// globalPromptVars are runtime variables available to EVERY agent (built-in and
|
|
// custom) regardless of its per-agent catalog. Each agent's render path fills them
|
|
// (see agent.nowStr, rendered fresh each turn), so a prompt may always reference
|
|
// {{.Now}} — e.g. subtract it from a fixed start stamp to reason about elapsed time.
|
|
var globalPromptVars = []db.PromptVar{
|
|
{Name: "Now", Description: "서버 현재 시각(실행할 때마다 실시간으로 갱신되며, 고정된 시작 시각에서 빼면 경과 시간을 계산할 수 있습니다)", Example: "2026-08-11 14:30:00 CST", Source: "runtime"},
|
|
{Name: "DataDir", Description: "서버 데이터 루트 디렉터리(모든 작업/대화 산출물의 루트이며, 각 에이전트는 그 아래 하위 디렉터리에 실제로 기록합니다. 예: <DataDir>/<taskID>)", Example: "/app/data", Source: "runtime"},
|
|
}
|
|
|
|
// withGlobalVars appends the universal runtime vars onto an agent's own catalog,
|
|
// so validation / the UI variable list / preview all recognize {{.Now}} etc.
|
|
// A stored catalog entry that collides with a global name is dropped: the global
|
|
// runtime var is authoritative (it's what the render path actually resolves), and
|
|
// this keeps the returned list name-unique so the UI never sees duplicate keys.
|
|
func withGlobalVars(vars []db.PromptVar) []db.PromptVar {
|
|
globalNames := make(map[string]bool, len(globalPromptVars))
|
|
for _, g := range globalPromptVars {
|
|
globalNames[g.Name] = true
|
|
}
|
|
out := make([]db.PromptVar, 0, len(vars)+len(globalPromptVars))
|
|
for _, v := range vars {
|
|
if globalNames[v.Name] {
|
|
continue // shadowed by the authoritative global runtime var
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return append(out, globalPromptVars...)
|
|
}
|
|
|
|
// validateTemplate parses the template and rejects any {{.Var}} not in the catalog.
|
|
// Returns "" if valid, otherwise an error message.
|
|
func validateTemplate(tmpl string, catalog []db.PromptVar) string {
|
|
t, err := template.New("p").Option("missingkey=error").Parse(tmpl)
|
|
if err != nil {
|
|
return errMgmtTmplSyntax + err.Error()
|
|
}
|
|
allowed := map[string]bool{}
|
|
for _, v := range catalog {
|
|
allowed[v.Name] = true
|
|
}
|
|
for _, name := range templateFields(t) {
|
|
if !allowed[name] {
|
|
return errMgmtTmplVarPre + name + errMgmtTmplVarPost
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// renderPrompt renders with example values (catalog example overridden by sample).
|
|
func renderPrompt(tmpl string, catalog []db.PromptVar, sample map[string]string) (string, error) {
|
|
t, err := template.New("p").Option("missingkey=error").Parse(tmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data := map[string]any{}
|
|
for _, v := range catalog {
|
|
data[v.Name] = v.Example
|
|
}
|
|
for k, val := range sample {
|
|
data[k] = val
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.Execute(&buf, data); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
// templateFields returns the distinct top-level {{.X}} field names referenced by
|
|
// the template (used to validate against the catalog whitelist).
|
|
func templateFields(t *template.Template) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
collect := func(p *parse.PipeNode) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
for _, cmd := range p.Cmds {
|
|
for _, arg := range cmd.Args {
|
|
if f, ok := arg.(*parse.FieldNode); ok && len(f.Ident) > 0 && !seen[f.Ident[0]] {
|
|
seen[f.Ident[0]] = true
|
|
out = append(out, f.Ident[0])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
var walk func(n parse.Node)
|
|
walk = func(n parse.Node) {
|
|
switch x := n.(type) {
|
|
case *parse.ListNode:
|
|
if x == nil {
|
|
return
|
|
}
|
|
for _, c := range x.Nodes {
|
|
walk(c)
|
|
}
|
|
case *parse.ActionNode:
|
|
collect(x.Pipe)
|
|
case *parse.IfNode:
|
|
collect(x.Pipe)
|
|
walk(x.List)
|
|
walk(x.ElseList)
|
|
case *parse.RangeNode:
|
|
collect(x.Pipe)
|
|
walk(x.List)
|
|
walk(x.ElseList)
|
|
case *parse.WithNode:
|
|
collect(x.Pipe)
|
|
walk(x.List)
|
|
walk(x.ElseList)
|
|
}
|
|
}
|
|
if t.Tree != nil {
|
|
walk(t.Tree.Root)
|
|
}
|
|
return out
|
|
}
|