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
docs / links (push) Waiting to run
web / web (push) Waiting to run
2299 lines
70 KiB
Go
2299 lines
70 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 = "仍有运行中的 Agent,已取消任务删除"
|
||
|
||
// 에이전트 CRUD
|
||
errMgmtAgentKeyFormat = "key 必须以小写字母开头,且只能使用小写字母、数字和下划线"
|
||
errMgmtNameEmpty = "名称不能为空"
|
||
errMgmtAgentKeyExists = "key 已存在"
|
||
errMgmtBuiltinNoEditMeta = "内置 Agent 无法修改名称/描述"
|
||
errMgmtBuiltinNoDelete = "内置 Agent 无法删除"
|
||
errMgmtLLMProfileIDFormat = "llm_profile_id 格式无效"
|
||
errMgmtLLMProfileInvalid = "指定的 LLM 配置不存在或无效"
|
||
errMgmtNoBuiltinPrompt = "该 Agent 没有内置默认提示词,无法还原"
|
||
|
||
// 도구 (뒤에 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 = "}} 不在该 Agent 的允许列表中"
|
||
)
|
||
|
||
// 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: "服务器数据根目录(所有任务/对话产物的根目录,各 Agent 实际写入其下的子目录。例如: <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
|
||
}
|