详情页 /function/findings/detail?id=<finding_id>:
- 概览 Tab:左栏摘要 + 证据/PoC,右栏状态区(严重等级、漏洞类型、涉及资产、处理状态、所属任务、发现时间)
- 严重等级与处理状态用下拉,选择后直接调后端接口即时修正
- 另有「证据/PoC」「元数据」两个 Tab
- 发现列表页漏洞名称改为链接进入详情
后端:
- GET /api/exploration/findings/{id} 取单条(含资产解析)
- PATCH /api/exploration/findings/{id} 扩展为部分更新 {status?, severity?}
- db.GetFinding / db.SetFindingSeverity(同步探索节点 payload,保持任务内视图一致)
- db.ValidSeverity 白名单;api 增加 getFinding / setFindingSeverity
- mock 支持单条 GET / PATCH,并回填 finding_id 供 demo 链接
db 测试补充 GetFinding / SetFindingSeverity 往返断言;next build 通过。
2422 lines
88 KiB
Go
2422 lines
88 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/url"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/Autumn-27/artex/agent"
|
||
"github.com/Autumn-27/artex/db"
|
||
"github.com/Autumn-27/artex/llmrec"
|
||
"github.com/Autumn-27/artex/report"
|
||
"github.com/Autumn-27/norma/llm"
|
||
"github.com/Autumn-27/norma/memory"
|
||
actool "github.com/Autumn-27/norma/tool"
|
||
"github.com/Autumn-27/norma/transcript"
|
||
)
|
||
|
||
// BuildVersion is the backend application version, injected from cmd/artex at
|
||
// startup (which in turn gets it from -ldflags "-X main.version=<tag>").
|
||
// Defaults to "dev" for local builds. Exposed to the frontend via GET /api/health.
|
||
var BuildVersion = "dev"
|
||
|
||
// Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui
|
||
// frontend.
|
||
type Server struct {
|
||
m *Manager
|
||
engine *Engine
|
||
ctx context.Context
|
||
|
||
skillDir string // root directory for skill subdirectories
|
||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||
|
||
cfgMu sync.Mutex
|
||
mainAgent *agent.MainAgent // nil when no LLM provider is configured
|
||
chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM
|
||
llmCfg agent.Config // current LLM config (key not exposed)
|
||
llmOn bool
|
||
llmProf string // active LLM profile name (for llmrec tagging)
|
||
|
||
// chatBusy guards the per-task main-agent run: the chat handler launches the
|
||
// agent on the server's background ctx (not the request ctx) and returns
|
||
// immediately, so a page reload / proxy timeout can't abort a live run. This
|
||
// map serializes turns per task — one main-agent run at a time per task, so
|
||
// concurrent messages don't corrupt the shared exp<id>-main transcript.
|
||
chatMu sync.Mutex
|
||
chatBusy map[string]bool
|
||
// chatCancel holds the cancel func for each in-flight conversation run (keyed by
|
||
// convBusyKey), so a manual stop can abort JUST that session's agent run. Set/
|
||
// cleared alongside chatBusy under chatMu. Aborting a run does NOT touch the P3
|
||
// trigger queue — the drain goroutine simply proceeds to the next queued fire.
|
||
chatCancel map[string]context.CancelFunc
|
||
|
||
// triggerQ buffers P3 trigger fires PER AGENT. A per-agent "pump" launches runs up
|
||
// to a concurrency limit derived from the agent's策略: serial → limit 1 (+ optional
|
||
// merge); parallel → limit = trigger_max_parallel (0=∞), no merge. triggerActive
|
||
// counts in-flight runs per agent (replaces a boolean drain flag); a run's
|
||
// completion decrements it and re-pumps to fill the freed slot. triggerCfg caches
|
||
// the agent's last-read策略 so the pump never queries the DB while holding queueMu.
|
||
// Distinct agents always run concurrently. Queue is in-memory (matches chatBusy); a
|
||
// restart drops pending fires — the scheduler re-fires from watermarks next tick.
|
||
queueMu sync.Mutex
|
||
triggerQ map[string][]triggeredRun
|
||
triggerActive map[string]int
|
||
triggerCfg map[string]triggerBehavior
|
||
|
||
// profAgents caches the dedicated planner/worker built for a specific (non-active)
|
||
// LLM profile, keyed by profile id, so tasks pinned to that profile share one
|
||
// provider (and its rate limiter). Built lazily on first use; invalidated when any
|
||
// profile is saved/activated/deleted so edits take effect. The active-profile path
|
||
// stays on the global engine planner/worker (applyLLM).
|
||
profMu sync.Mutex
|
||
profAgents map[int64]*profBundle
|
||
profChatAgents map[int64]*agent.ChatAgent // per-profile ChatAgent cache (chat page)
|
||
|
||
// provByProfile caches ONE provider per LLM profile id so every agent bound or
|
||
// pinned to the same profile shares a single provider instance — hence one rate
|
||
// limiter. Separate from profAgents (whole planner/worker pairs). The globally-active
|
||
// provider (built in applyLLM) lives OUTSIDE this cache, so binding an agent to the
|
||
// currently-active profile builds a second instance — harmless (that binding equals
|
||
// leaving it unset), just not deduped. Cleared alongside profAgents on profile edits.
|
||
provCacheMu sync.Mutex
|
||
provByProfile map[int64]*provEntry
|
||
}
|
||
|
||
// profBundle is a planner/worker pair built from one LLM profile.
|
||
type profBundle struct {
|
||
pl *agent.Planner
|
||
wk *agent.Worker
|
||
}
|
||
|
||
// provEntry is a cached provider + its config for one LLM profile id.
|
||
type provEntry struct {
|
||
prov llm.Provider
|
||
cfg agent.Config
|
||
}
|
||
|
||
// triggeredRun is one queued P3 trigger fire awaiting its turn for an agent.
|
||
// taskID + mergeable let the drainer coalesce several event triggers (finding/goal)
|
||
// from the SAME task into one conversation before it starts (interval fires don't merge).
|
||
type triggeredRun struct {
|
||
agentKey string
|
||
title string
|
||
message string
|
||
taskID int64 // source task for finding/goal triggers; 0 for interval/none
|
||
mergeable bool // true for finding/goal event triggers (merge by taskID)
|
||
}
|
||
|
||
func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDir string) *Server {
|
||
key, err := loadOrCreateJWTKey(keyDir, dataDir)
|
||
if err != nil {
|
||
log.Fatalf("[auth] JWT key: %v", err)
|
||
}
|
||
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{},
|
||
chatCancel: map[string]context.CancelFunc{}, triggerQ: map[string][]triggeredRun{},
|
||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||
profAgents: map[int64]*profBundle{}, profChatAgents: map[int64]*agent.ChatAgent{},
|
||
provByProfile: map[int64]*provEntry{}}
|
||
// per-task LLM: a task pinned to a specific profile runs on that profile's
|
||
// dedicated planner/worker; unpinned tasks fall back to the global active pair.
|
||
s.engine.SetAgentResolver(func(t *Task) (*agent.Planner, *agent.Worker) {
|
||
if t.LLMProfileID == nil {
|
||
return nil, nil
|
||
}
|
||
return s.agentsForProfile(*t.LLMProfileID)
|
||
})
|
||
// Wire DB-stored prompt templates into the agents (新版方案 §3.3 / §5a). With no
|
||
// override row, agents keep their built-in defaults — behavior is unchanged.
|
||
if m.pg != nil {
|
||
agent.PromptOverride = func(key string) (string, bool) {
|
||
a, err := m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil {
|
||
return "", false
|
||
}
|
||
t, err := m.pg.CurrentPrompt(a.ID)
|
||
if err != nil || t == "" {
|
||
return "", false
|
||
}
|
||
return t, true
|
||
}
|
||
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
|
||
agent.WrapupOverride = func(key string) (string, bool) {
|
||
a, err := m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil || a.WrapupPrompt == "" {
|
||
return "", false
|
||
}
|
||
return a.WrapupPrompt, true
|
||
}
|
||
// Wire DB-stored wrap-up turn budgets. 0 / missing → built-in per-agent default.
|
||
agent.WrapupMaxTurnsOverride = func(key string) (int, bool) {
|
||
a, err := m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil || a.WrapupMaxTurns <= 0 {
|
||
return 0, false
|
||
}
|
||
return a.WrapupMaxTurns, true
|
||
}
|
||
// Wire DB-stored TASK-TIMEOUT wrap-up prompt/turns (worker/planner). Empty/0 → default.
|
||
agent.WrapupTaskTimeoutOverride = func(key string) (string, bool) {
|
||
a, err := m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil || a.TaskTimeoutWrapupPrompt == "" {
|
||
return "", false
|
||
}
|
||
return a.TaskTimeoutWrapupPrompt, true
|
||
}
|
||
agent.WrapupTaskTimeoutTurnsOverride = func(key string) (int, bool) {
|
||
a, err := m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil || a.TaskTimeoutWrapupMaxTurns <= 0 {
|
||
return 0, false
|
||
}
|
||
return a.TaskTimeoutWrapupMaxTurns, true
|
||
}
|
||
wireAgentAugment(m.pg, s.skillDir, s.hostTools) // 可见 skills/MCP + 流量/编排 host 工具装配进 agent 工具集
|
||
domainReg := buildDomainReg(m.Assets())
|
||
wireTools(m.pg, domainReg) // 内置工具表:按 agent 过滤 + 覆盖描述/schema + 注入默认值
|
||
seedPrompts(m.pg) // 内置 agent 默认提示词正文播种进 agent_prompts(仅空时)
|
||
s.seedOrchestrationTools() // P2 跨任务编排工具 seed 进 tools 表(可按 agent 绑定)
|
||
s.seedPythonInterpreter() // 自定义脚本工具:开机检测 python 解释器入库(仅空时)
|
||
go newScheduler(s).Run(s.ctx) // P3 触发器调度(定时/finding/目标事件),仅自定义 agent
|
||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||
go s.discoverEmptyMCPsOnStartup()
|
||
logSink.SetDB(ctx, m.pg) // restore last 100 log rows and enable async persistence
|
||
}
|
||
// precedence: persisted DB config > env.
|
||
if cfg, ok := s.loadLLMConfig(); ok {
|
||
if err := s.applyLLM(cfg); err != nil {
|
||
log.Printf("[engine] saved LLM config init failed — engine idle: %v", err)
|
||
} else {
|
||
log.Printf("[engine] LLM configured from DB: %s / %s", cfg.Provider(), cfg.Model)
|
||
}
|
||
} else if cfg, ok := agent.FromEnv(); ok {
|
||
if err := s.applyLLM(cfg); err != nil {
|
||
log.Printf("[engine] env provider init failed — engine idle: %v", err)
|
||
} else {
|
||
log.Printf("[engine] LLM configured from env: %s / %s", cfg.Provider(), cfg.Model)
|
||
}
|
||
} else {
|
||
log.Printf("[engine] no LLM provider configured — engine idle until set via /api/llm or env")
|
||
}
|
||
// reload tasks persisted on disk so the task list survives a restart, and
|
||
// restore persisted paused state (so a task paused before restart stays paused).
|
||
for _, t := range m.LoadExisting() {
|
||
// clear stale 'running' intents from a prior crash/restart (no live worker
|
||
// owns them) so they re-claim instead of spinning forever in the UI.
|
||
if n, _ := t.Store.ResetRunningIntents(); n > 0 {
|
||
log.Printf("[engine] task %s 重置 %d 个残留 running 意图为 open", t.ID, n)
|
||
}
|
||
if t.Paused {
|
||
s.engine.Pause(t.ID)
|
||
}
|
||
// 任务级超时:为每个未终态、带 timeout 的任务起 deadline 协调器,独立于 planner/worker
|
||
// loop——非活跃任务重启后也能在到点后被收尾(deadline 已过则立即走收尾时序)。
|
||
if !isTerminalStatus(t.Status) {
|
||
s.engine.startDeadlineCoordinator(ctx, t)
|
||
}
|
||
}
|
||
// resume the active task's engine; other tasks resume when opened (setActive).
|
||
// (a restored-paused task's loops still start but idle until resumed.)
|
||
if t := m.ActiveTask(); t != nil {
|
||
s.engine.Run(ctx, t)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// agentMaxTurns returns the configured max_turns for an agent key (0 = unlimited,
|
||
// also the fallback when no DB or no row).
|
||
func (s *Server) agentMaxTurns(key string) int {
|
||
if s.m.pg == nil {
|
||
return 0
|
||
}
|
||
a, err := s.m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil {
|
||
return 0
|
||
}
|
||
return a.MaxTurns
|
||
}
|
||
|
||
// agentRunSeconds returns the configured wall-clock run budget (seconds) for an
|
||
// agent key (0 = unlimited; 600 fallback when no DB or no row, matching schema).
|
||
func (s *Server) agentRunSeconds(key string) int {
|
||
if s.m.pg == nil {
|
||
return 600
|
||
}
|
||
a, err := s.m.pg.GetAgentByKey(key)
|
||
if err != nil || a == nil {
|
||
return 600
|
||
}
|
||
return a.RunSecs
|
||
}
|
||
|
||
// loadLLMConfig reads the active LLM profile from PG (llm_profiles).
|
||
func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||
p, err := s.m.pg.ActiveProfile()
|
||
if err != nil || p == nil {
|
||
return agent.Config{}, false
|
||
}
|
||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||
cfg.ContextWindowK = p.ContextWindowK
|
||
cfg.ReasoningEffort = p.ReasoningEffort
|
||
if cfg.APIKey == "" {
|
||
return cfg, false
|
||
}
|
||
s.cfgMu.Lock()
|
||
s.llmProf = p.Name
|
||
s.cfgMu.Unlock()
|
||
return cfg, true
|
||
}
|
||
|
||
// saveLLMConfig persists the LLM config as the active "default" profile in PG.
|
||
func (s *Server) saveLLMConfig(cfg agent.Config) {
|
||
format := cfg.Provider()
|
||
if format != "anthropic" {
|
||
format = "openai"
|
||
}
|
||
var id int64
|
||
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
|
||
for _, p := range profs {
|
||
if p.Name == "default" {
|
||
id = p.ID
|
||
break
|
||
}
|
||
}
|
||
}
|
||
newID, err := s.m.pg.SaveProfile(&db.LLMProfile{
|
||
ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy,
|
||
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
|
||
ContextWindowK: cfg.ContextWindowK, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
|
||
})
|
||
if err == nil {
|
||
_ = s.m.pg.SetActiveProfile(newID)
|
||
}
|
||
}
|
||
|
||
// reapplyActiveProfile hot-reloads the engine from the active DB profile so that
|
||
// saving or activating a profile takes effect without a restart. Best-effort:
|
||
// logs on failure and leaves the running engine untouched.
|
||
func (s *Server) reapplyActiveProfile() {
|
||
cfg, ok := s.loadLLMConfig()
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.applyLLM(cfg); err != nil {
|
||
log.Printf("[engine] reapply active profile failed: %v", err)
|
||
return
|
||
}
|
||
log.Printf("[engine] LLM reapplied from active profile: %s / %s", cfg.Provider(), cfg.Model)
|
||
}
|
||
|
||
// webSearchFor gates the global web-search opts (backend/key) by an agent's own
|
||
// web_search flag: the backend/key come from the global config, each agent decides on/off.
|
||
func (s *Server) webSearchFor(key string) agent.WebSearchOpts {
|
||
o := s.m.WebSearchOpts()
|
||
if a, err := s.m.pg.GetAgentByKey(key); err != nil || a == nil || !a.WebSearch {
|
||
o.Enabled = false
|
||
}
|
||
return o
|
||
}
|
||
|
||
// buildPlannerWorker builds a planner+worker pair. Each agent resolves its OWN LLM by
|
||
// precedence agent-binding → pin → the passed global fallback (gProv/gCfg), so planner
|
||
// and worker can run on different models (e.g. a stronger planner, a cheaper worker).
|
||
// pinID is the task's pinned profile (nil on the global active path). With no agent
|
||
// binding and no pin, both fall back to gProv/gCfg — identical to the previous single-
|
||
// provider behavior. Shared by applyLLM (global) and agentsForProfile (per-task pin).
|
||
func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent.Config) (*agent.Planner, *agent.Worker) {
|
||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts")) // raw LLM conversation logs
|
||
// traffic host tools flow through ToolAugment for every agent and are filtered by
|
||
// the tools-table binding (default = worker), so worker behavior is unchanged.
|
||
wProv, wCfg := s.providerForAgent("worker", pinID, gProv, gCfg)
|
||
wk := agent.NewWorker(wProv, wCfg.Model, s.m.dir, tx, wCfg.CompactionWindow(), s.agentMaxTurns("worker"))
|
||
wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
|
||
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
|
||
wk.SetWebSearch(s.webSearchFor("worker"))
|
||
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
|
||
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
|
||
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
||
pl.SetSteerWork(s.engine.SteerWork) // planner steer_work → inject mid-run course-correction
|
||
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||
pl.SetWebSearch(s.webSearchFor("planner"))
|
||
return pl, wk
|
||
}
|
||
|
||
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
|
||
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
|
||
func (s *Server) applyLLM(cfg agent.Config) error {
|
||
prov, err := cfg.NewProvider()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// Wrap provider with LLM call recorder (persists request/response to PG).
|
||
s.cfgMu.Lock()
|
||
profName := s.llmProf
|
||
s.cfgMu.Unlock()
|
||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled)
|
||
pl, wk := s.buildPlannerWorker(nil, prov, cfg)
|
||
s.engine.UseLLM(pl, wk)
|
||
|
||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||
win := cfg.CompactionWindow()
|
||
// mainagent resolves its own binding (→ global fallback); chat stays the GLOBAL
|
||
// fallback since one ChatAgent serves many agent keys — its per-agent binding is
|
||
// resolved at Chat time (runConversationSync → chatAgentForProfile).
|
||
mProv, mCfg := s.providerForAgent("mainagent", nil, prov, cfg)
|
||
s.cfgMu.Lock()
|
||
s.mainAgent = agent.NewMainAgent(mProv, mCfg.Model, s.m.dir, tx, mCfg.CompactionWindow(), s.agentMaxTurns("mainagent"))
|
||
s.mainAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
|
||
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
|
||
// chat agent serves MANY custom agents by key → it holds the GLOBAL opts
|
||
// (backend/key) and gates Enabled per-conversation-agent at Chat time. 对话始终用激活配置。
|
||
s.chatAgent = agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win) // chat page runner
|
||
s.chatAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
|
||
s.chatAgent.SetGuard(s.chatGuard())
|
||
s.llmCfg = cfg
|
||
s.llmOn = true
|
||
s.cfgMu.Unlock()
|
||
|
||
// wake the active task so a task created while idle starts exploring.
|
||
if t := s.m.ActiveTask(); t != nil {
|
||
t.Notify()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// loadProfileConfig builds an agent.Config from a specific profile id (with its key).
|
||
// ok=false when the profile is missing or has no api key.
|
||
func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||
p, err := s.m.pg.ProfileByID(id)
|
||
if err != nil || p == nil {
|
||
return agent.Config{}, false
|
||
}
|
||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||
cfg.ContextWindowK = p.ContextWindowK
|
||
cfg.ReasoningEffort = p.ReasoningEffort
|
||
if cfg.APIKey == "" {
|
||
return cfg, false
|
||
}
|
||
return cfg, true
|
||
}
|
||
|
||
// effectiveProfileForAgent resolves the LLM profile id an agent should run on, by
|
||
// precedence: agent binding (agents.llm_profile_id) → pin (task/conversation) → nil
|
||
// (caller falls back to the global active profile). A binding to a deleted profile
|
||
// can't happen (FK ON DELETE SET NULL); an otherwise-invalid one is dropped downstream
|
||
// by loadProfileConfig, letting the caller fall back.
|
||
func (s *Server) effectiveProfileForAgent(agentKey string, pinID *int64) *int64 {
|
||
if s.m.pg != nil && agentKey != "" {
|
||
if a, _ := s.m.pg.GetAgentByKey(agentKey); a != nil && a.LLMProfileID != nil {
|
||
return a.LLMProfileID
|
||
}
|
||
}
|
||
return pinID
|
||
}
|
||
|
||
// providerForProfile returns a cached provider+cfg for a profile id, so every agent
|
||
// bound/pinned to the same profile shares one provider instance (one rate limiter).
|
||
// ok=false when the profile is missing/invalid → caller falls back to the global pair.
|
||
func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) {
|
||
s.provCacheMu.Lock()
|
||
if e := s.provByProfile[id]; e != nil {
|
||
s.provCacheMu.Unlock()
|
||
return e.prov, e.cfg, true
|
||
}
|
||
s.provCacheMu.Unlock()
|
||
cfg, ok := s.loadProfileConfig(id)
|
||
if !ok {
|
||
return nil, agent.Config{}, false
|
||
}
|
||
prov, err := cfg.NewProvider()
|
||
if err != nil {
|
||
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||
return nil, agent.Config{}, false
|
||
}
|
||
// Wrap with recorder, tagged with this profile's name.
|
||
if p, _ := s.m.pg.ProfileByID(id); p != nil {
|
||
prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled)
|
||
}
|
||
s.provCacheMu.Lock()
|
||
if e := s.provByProfile[id]; e != nil { // lost the race → keep the winner
|
||
prov, cfg = e.prov, e.cfg
|
||
} else {
|
||
s.provByProfile[id] = &provEntry{prov: prov, cfg: cfg}
|
||
log.Printf("[engine] built provider for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||
}
|
||
s.provCacheMu.Unlock()
|
||
return prov, cfg, true
|
||
}
|
||
|
||
// providerForAgent returns the provider+cfg one agent should run on: its bound/pinned
|
||
// profile if that resolves, else the passed global fallback (gProv/gCfg).
|
||
func (s *Server) providerForAgent(agentKey string, pinID *int64, gProv llm.Provider, gCfg agent.Config) (llm.Provider, agent.Config) {
|
||
if eff := s.effectiveProfileForAgent(agentKey, pinID); eff != nil {
|
||
if prov, cfg, ok := s.providerForProfile(*eff); ok {
|
||
return prov, cfg
|
||
}
|
||
}
|
||
return gProv, gCfg
|
||
}
|
||
|
||
// agentsForProfile returns the dedicated planner/worker for a task pinned to a specific
|
||
// LLM profile, built + cached on first use (tasks on the same pin share one pair). Each
|
||
// agent still honors its own binding first (via buildPlannerWorker), falling back to this
|
||
// pinned profile. nil,nil when the profile is invalid → caller uses the global active pair.
|
||
func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
|
||
s.profMu.Lock()
|
||
b := s.profAgents[id]
|
||
s.profMu.Unlock()
|
||
if b != nil {
|
||
return b.pl, b.wk
|
||
}
|
||
prov, cfg, ok := s.providerForProfile(id)
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
pl, wk := s.buildPlannerWorker(&id, prov, cfg)
|
||
s.profMu.Lock()
|
||
if ex := s.profAgents[id]; ex != nil { // lost the race → keep the winner
|
||
pl, wk = ex.pl, ex.wk
|
||
} else {
|
||
s.profAgents[id] = &profBundle{pl: pl, wk: wk}
|
||
}
|
||
s.profMu.Unlock()
|
||
return pl, wk
|
||
}
|
||
|
||
// chatAgentForProfile returns a ChatAgent built from a specific LLM profile, cached
|
||
// per profile id. Returns nil if the profile is missing or has no API key.
|
||
func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
|
||
s.profMu.Lock()
|
||
cached := s.profChatAgents[id]
|
||
s.profMu.Unlock()
|
||
if cached != nil {
|
||
return cached
|
||
}
|
||
prov, cfg, ok := s.providerForProfile(id)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, cfg.CompactionWindow())
|
||
ca.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||
ca.SetWebSearch(s.m.WebSearchOpts())
|
||
ca.SetGuard(s.chatGuard())
|
||
s.profMu.Lock()
|
||
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
|
||
ca = ex
|
||
} else {
|
||
s.profChatAgents[id] = ca
|
||
}
|
||
s.profMu.Unlock()
|
||
return ca
|
||
}
|
||
|
||
// invalidateProfileAgents drops the per-profile agent + provider caches so a profile
|
||
// save/activate/delete — or an agent's binding change — rebuilds pinned tasks' planner/
|
||
// worker (and re-resolves each agent's bound model) on their next round.
|
||
func (s *Server) invalidateProfileAgents() {
|
||
s.profMu.Lock()
|
||
s.profAgents = map[int64]*profBundle{}
|
||
s.profChatAgents = map[int64]*agent.ChatAgent{}
|
||
s.profMu.Unlock()
|
||
s.provCacheMu.Lock()
|
||
s.provByProfile = map[int64]*provEntry{}
|
||
s.provCacheMu.Unlock()
|
||
}
|
||
|
||
func (s *Server) mainAgentRef() *agent.MainAgent {
|
||
s.cfgMu.Lock()
|
||
defer s.cfgMu.Unlock()
|
||
return s.mainAgent
|
||
}
|
||
|
||
func (s *Server) chatAgentRef() *agent.ChatAgent {
|
||
s.cfgMu.Lock()
|
||
defer s.cfgMu.Unlock()
|
||
return s.chatAgent
|
||
}
|
||
|
||
func (s *Server) Handler() http.Handler {
|
||
mux := http.NewServeMux()
|
||
|
||
// Auth routes — exempt from JWT check (handled in requireAuth)
|
||
mux.HandleFunc("GET /api/auth/status", s.authStatus)
|
||
mux.HandleFunc("POST /api/auth/init", s.authInit)
|
||
mux.HandleFunc("POST /api/auth/login", s.authLogin)
|
||
mux.HandleFunc("POST /api/auth/change-password", s.authChangePassword)
|
||
|
||
mux.HandleFunc("GET /api/health", s.health)
|
||
mux.HandleFunc("GET /api/stats", s.stats)
|
||
mux.HandleFunc("GET /api/logs", s.getLogs)
|
||
mux.HandleFunc("GET /api/logs/history", s.getLogsHistory)
|
||
mux.HandleFunc("GET /api/logs/stream", s.streamLogs)
|
||
|
||
mux.HandleFunc("GET /api/tasks", s.listTasks)
|
||
mux.HandleFunc("POST /api/tasks", s.createTask)
|
||
mux.HandleFunc("GET /api/tasks/{id}", s.getTask)
|
||
mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage)
|
||
mux.HandleFunc("GET /api/tasks/{id}/coverage-graph", s.taskCoverageGraph)
|
||
mux.HandleFunc("GET /api/tasks/{id}/asset-refs", s.taskAssetRefs)
|
||
|
||
// 工作空间文件管理器(针对 workDir)
|
||
mux.HandleFunc("GET /api/workspace/list", s.wsList)
|
||
mux.HandleFunc("GET /api/workspace/read", s.wsRead)
|
||
mux.HandleFunc("POST /api/workspace/write", s.wsWrite)
|
||
mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir)
|
||
mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete)
|
||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||
mux.HandleFunc("POST /api/tasks/{id}/control", s.control)
|
||
mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/rerun", s.rerunIntent) // 重跑单条 blocked/exhausted/stopped 意图
|
||
mux.HandleFunc("POST /api/tasks/{id}/intents/rerun-blocked", s.rerunBlocked) // 批量重跑本任务全部 blocked 意图
|
||
mux.HandleFunc("POST /api/active", s.setActive)
|
||
|
||
mux.HandleFunc("GET /api/llm", s.getLLM)
|
||
mux.HandleFunc("POST /api/llm", s.setLLM)
|
||
mux.HandleFunc("POST /api/llm/test", s.testLLM)
|
||
|
||
// asset system
|
||
mux.HandleFunc("GET /api/assets", s.listAssets)
|
||
mux.HandleFunc("GET /api/assets/counts", s.assetCounts)
|
||
mux.HandleFunc("POST /api/assets", s.insertAssets)
|
||
mux.HandleFunc("DELETE /api/assets", s.deleteAssets)
|
||
|
||
// company system
|
||
mux.HandleFunc("GET /api/companies", s.listCompanies)
|
||
mux.HandleFunc("POST /api/companies", s.createCompany)
|
||
mux.HandleFunc("GET /api/companies/{id}", s.getCompany)
|
||
mux.HandleFunc("DELETE /api/companies/{id}", s.deleteCompany)
|
||
mux.HandleFunc("POST /api/companies/{id}/scope", s.addCompanyScope)
|
||
mux.HandleFunc("POST /api/companies/reattribute", s.reattribute)
|
||
|
||
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
|
||
mux.HandleFunc("GET /api/exploration/findings", s.findings)
|
||
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
|
||
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
|
||
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
|
||
mux.HandleFunc("GET /api/exploration/intents", s.intents)
|
||
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
|
||
mux.HandleFunc("GET /api/exploration/activity", s.activity)
|
||
mux.HandleFunc("GET /api/exploration/activity/history", s.activityHistory)
|
||
mux.HandleFunc("GET /api/exploration/activity/stream", s.streamActivity)
|
||
mux.HandleFunc("GET /api/exploration/activity/{seq}", s.activityDetail)
|
||
mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats)
|
||
mux.HandleFunc("GET /api/tokens/daily", s.tokenDailyStats)
|
||
mux.HandleFunc("GET /api/tokens/conversations", s.conversationTokens)
|
||
|
||
mux.HandleFunc("GET /api/audit", s.getAudit)
|
||
mux.HandleFunc("POST /api/gc", s.gc)
|
||
mux.HandleFunc("GET /api/traffic", s.getTraffic)
|
||
mux.HandleFunc("GET /api/traffic/hosts", s.getTrafficHosts)
|
||
mux.HandleFunc("DELETE /api/traffic", s.deleteTraffic)
|
||
mux.HandleFunc("DELETE /api/traffic/hosts", s.deleteTrafficHosts)
|
||
mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange)
|
||
mux.HandleFunc("GET /api/commands", s.pgListCommands)
|
||
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
|
||
mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords)
|
||
mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks)
|
||
mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord)
|
||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
|
||
mux.HandleFunc("GET /api/report", s.getReport)
|
||
mux.HandleFunc("POST /api/chat", s.chat)
|
||
mux.HandleFunc("POST /api/chat/upload", s.chatUpload) // 方式1 文件上传:落到会话/任务工作目录 uploads/
|
||
mux.HandleFunc("POST /api/tasks/{id}/chat/stop", s.stopChat)
|
||
|
||
// --- 管理后台 API (PostgreSQL 数据源; 新版数据库与管理后台方案) ---
|
||
mux.HandleFunc("DELETE /api/tasks/{id}", s.pgDeleteTask)
|
||
// Agents
|
||
// conversations (chat page)
|
||
mux.HandleFunc("GET /api/conversations", s.pgListConversations)
|
||
mux.HandleFunc("POST /api/conversations", s.pgCreateConversation)
|
||
mux.HandleFunc("PATCH /api/conversations/{id}", s.pgRenameConversation)
|
||
mux.HandleFunc("PATCH /api/conversations/{id}/profile", s.pgUpdateConversation)
|
||
mux.HandleFunc("DELETE /api/conversations/{id}", s.pgDeleteConversation)
|
||
mux.HandleFunc("GET /api/conversations/{id}/messages", s.pgConversationMessages)
|
||
mux.HandleFunc("POST /api/conversations/{id}/messages", s.pgSendConversationMessage)
|
||
mux.HandleFunc("POST /api/conversations/{id}/stop", s.pgStopConversation)
|
||
mux.HandleFunc("GET /api/conversations/{id}/messages/{seq}", s.pgConversationMsgDetail)
|
||
|
||
mux.HandleFunc("GET /api/agents", s.pgListAgents)
|
||
mux.HandleFunc("POST /api/agents", s.pgCreateAgent)
|
||
mux.HandleFunc("GET /api/agents/{key}", s.pgGetAgent)
|
||
mux.HandleFunc("PATCH /api/agents/{key}", s.pgUpdateAgent)
|
||
mux.HandleFunc("DELETE /api/agents/{key}", s.pgDeleteAgent)
|
||
mux.HandleFunc("PUT /api/agents/{key}/config", s.pgSaveAgentConfig)
|
||
mux.HandleFunc("PUT /api/agents/{key}/prompt", s.pgSavePrompt)
|
||
mux.HandleFunc("POST /api/agents/{key}/prompt/reset", s.pgResetPrompt)
|
||
mux.HandleFunc("PUT /api/agents/{key}/wrapup", s.pgSaveWrapup)
|
||
mux.HandleFunc("POST /api/agents/{key}/wrapup/reset", s.pgResetWrapup)
|
||
mux.HandleFunc("PUT /api/agents/{key}/wrapup/task-timeout", s.pgSaveTaskTimeoutWrapup)
|
||
mux.HandleFunc("POST /api/agents/{key}/wrapup/task-timeout/reset", s.pgResetTaskTimeoutWrapup)
|
||
mux.HandleFunc("GET /api/agents/{key}/triggers", s.pgListTriggers)
|
||
mux.HandleFunc("POST /api/agents/{key}/triggers", s.pgCreateTrigger)
|
||
mux.HandleFunc("PATCH /api/triggers/{id}", s.pgUpdateTrigger)
|
||
mux.HandleFunc("DELETE /api/triggers/{id}", s.pgDeleteTrigger)
|
||
mux.HandleFunc("GET /api/agents/{key}/prompts", s.pgListPromptVersions)
|
||
mux.HandleFunc("GET /api/agents/{key}/variables", s.pgPromptVars)
|
||
mux.HandleFunc("POST /api/agents/{key}/prompt/preview", s.pgPreviewPrompt)
|
||
mux.HandleFunc("GET /api/agents/{key}/visibility", s.pgGetAgentVisibility)
|
||
mux.HandleFunc("PUT /api/agents/{key}/visibility", s.pgSetAgentVisibility)
|
||
// 内置工具目录(描述/参数默认值可改、按 agent 绑定;key 与 handler 在代码层)
|
||
mux.HandleFunc("GET /api/tools", s.pgListTools)
|
||
mux.HandleFunc("PUT /api/tools/{key}", s.pgUpdateTool)
|
||
mux.HandleFunc("POST /api/tools/custom", s.pgCreateCustomTool)
|
||
mux.HandleFunc("POST /api/tools/custom/test", s.pgTestCustomTool)
|
||
mux.HandleFunc("PUT /api/tools/custom/{key}", s.pgUpdateCustomTool)
|
||
mux.HandleFunc("DELETE /api/tools/custom/{key}", s.pgDeleteCustomTool)
|
||
mux.HandleFunc("POST /api/settings/python/detect", s.pgDetectPython)
|
||
mux.HandleFunc("POST /api/tools/{key}/reset", s.pgResetTool)
|
||
// MCP CRUD
|
||
mux.HandleFunc("GET /api/mcp", s.pgListMCP)
|
||
mux.HandleFunc("POST /api/mcp", s.pgSaveMCP)
|
||
mux.HandleFunc("DELETE /api/mcp/{id}", s.pgDeleteMCP)
|
||
mux.HandleFunc("GET /api/mcp/{id}/tools", s.pgMCPTools)
|
||
mux.HandleFunc("POST /api/mcp/{id}/refresh", s.pgRefreshMCP)
|
||
// 资产同步 — ScopeSentry 数据源
|
||
mux.HandleFunc("GET /api/sync/scopesentry/status", s.syncSSStatus)
|
||
mux.HandleFunc("POST /api/sync/scopesentry/datasource", s.syncSSDatasource)
|
||
mux.HandleFunc("GET /api/sync/scopesentry/projects", s.syncSSProjects)
|
||
mux.HandleFunc("GET /api/sync/scopesentry/tasks", s.syncSSTasks)
|
||
mux.HandleFunc("POST /api/sync/scopesentry/sync", s.syncSSRun)
|
||
// Skill CRUD (文件系统)
|
||
mux.HandleFunc("GET /api/skills", s.fsListSkills)
|
||
mux.HandleFunc("POST /api/skills", s.fsCreateSkill)
|
||
mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill)
|
||
mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill)
|
||
mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta)
|
||
mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir)
|
||
mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles)
|
||
// {file...} captures path segments including slashes (e.g. scripts/extract.py)
|
||
mux.HandleFunc("GET /api/skills/{name}/files/{file...}", s.fsReadFile)
|
||
mux.HandleFunc("PUT /api/skills/{name}/files/{file...}", s.fsWriteFile)
|
||
mux.HandleFunc("DELETE /api/skills/{name}/files/{file...}", s.fsDeletePath)
|
||
// MCP 资源侧可见性(更具体的 skill 路由会优先匹配)
|
||
mux.HandleFunc("GET /api/visibility/{kind}/{id}", s.pgResourceVisibility)
|
||
mux.HandleFunc("POST /api/visibility/toggle", s.pgToggleVisibility)
|
||
// Skill 可见性(按名称,更具体,优先于上面的通配路由)
|
||
mux.HandleFunc("GET /api/visibility/skill/{name}", s.pgSkillVisibility)
|
||
mux.HandleFunc("POST /api/visibility/skill/toggle", s.pgToggleSkillVisibility)
|
||
// LLM 多 profile
|
||
mux.HandleFunc("GET /api/llm/profiles", s.pgListProfiles)
|
||
mux.HandleFunc("POST /api/llm/profiles", s.pgSaveProfile)
|
||
mux.HandleFunc("DELETE /api/llm/profiles/{id}", s.pgDeleteProfile)
|
||
mux.HandleFunc("POST /api/llm/profiles/active", s.pgActivateProfile)
|
||
mux.HandleFunc("POST /api/llm/models", s.pgListModels)
|
||
|
||
// 拦截规则管理
|
||
mux.HandleFunc("GET /api/intercept/rules", s.interceptListRules)
|
||
mux.HandleFunc("POST /api/intercept/rules", s.interceptCreateRule)
|
||
mux.HandleFunc("PUT /api/intercept/rules/{id}", s.interceptUpdateRule)
|
||
mux.HandleFunc("DELETE /api/intercept/rules/{id}", s.interceptDeleteRule)
|
||
mux.HandleFunc("POST /api/intercept/rules/{id}/toggle", s.interceptToggleRule)
|
||
mux.HandleFunc("GET /api/intercept/pending", s.interceptListPending)
|
||
mux.HandleFunc("GET /api/intercept/pending/{id}", s.interceptGetOne)
|
||
mux.HandleFunc("POST /api/intercept/pending/{id}/decide", s.interceptDecide)
|
||
mux.HandleFunc("GET /api/intercept/history", s.interceptHistory)
|
||
mux.HandleFunc("GET /api/intercept/task/{taskID}", s.interceptListTaskItems)
|
||
mux.HandleFunc("GET /api/intercept/tool-config", s.interceptGetToolConfig)
|
||
mux.HandleFunc("PUT /api/intercept/tool-config", s.interceptSetToolConfig)
|
||
|
||
// /api/* goes through CORS + JWT; everything else is served by the embedded
|
||
// frontend (public — auth is enforced client-side and on the API). With the
|
||
// no-embed build the webui handler just 404s (run `next dev` separately).
|
||
api := cors(s.requireAuth(mux))
|
||
root := http.NewServeMux()
|
||
root.Handle("/api/", api)
|
||
root.Handle("/", s.webuiHandler())
|
||
return root
|
||
}
|
||
|
||
// --- handlers ---
|
||
|
||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, 200, map[string]any{"ok": true, "service": "artex", "version": BuildVersion})
|
||
}
|
||
|
||
func (s *Server) stats(w http.ResponseWriter, r *http.Request) {
|
||
out := map[string]any{}
|
||
out["engine_mode"] = "idle" // spec enum; overridden below when a task is active
|
||
out["llm_configured"] = s.engine.Ready() // is an LLM provider installed at all
|
||
if tr := s.m.Traffic(); tr != nil {
|
||
c, _ := tr.Count()
|
||
out["traffic"] = c
|
||
out["traffic_enabled"] = true
|
||
}
|
||
if counts, err := s.m.Assets().CountsByType(); err == nil {
|
||
total := 0
|
||
for _, n := range counts {
|
||
total += n
|
||
}
|
||
out["assets"] = total
|
||
out["asset_counts"] = counts
|
||
}
|
||
|
||
// resolve the task: explicit ?task=<id> binds to that task (so a detail view
|
||
// never silently follows a globally-changed active task); empty = active.
|
||
taskParam := r.URL.Query().Get("task")
|
||
t := s.m.ResolveTask(taskParam)
|
||
if t == nil {
|
||
if taskParam != "" && taskParam != "active" {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
writeJSON(w, 200, out) // no task selected: only global fields
|
||
return
|
||
}
|
||
|
||
st, _ := t.Store.Stats()
|
||
out["exploration"] = st
|
||
|
||
// per-task running state + heartbeat (distinct from "LLM configured").
|
||
intents, _ := t.Store.ListByKind(db.KindIntent, 100000)
|
||
inFlight := 0
|
||
for _, in := range intents {
|
||
if in.State == "running" {
|
||
inFlight++
|
||
}
|
||
}
|
||
goals, _ := t.Store.ListByKind(db.KindGoal, 10000)
|
||
goalsMet := 0
|
||
for _, g := range goals {
|
||
if g.State == "met" {
|
||
goalsMet++
|
||
}
|
||
}
|
||
last := s.engine.LastActivity(t.ID)
|
||
paused := s.engine.IsPaused(t.ID)
|
||
running := s.engine.Ready() && s.engine.Started(t.ID) && !paused
|
||
// stalled: running but no activity for a while and nothing in flight.
|
||
stalled := running && inFlight == 0 && last > 0 && time.Now().Unix()-last > 120
|
||
|
||
// engine_mode follows the spec enum (exploring|paused|stalled|idle).
|
||
switch {
|
||
case paused:
|
||
out["engine_mode"] = "paused"
|
||
case stalled:
|
||
out["engine_mode"] = "stalled"
|
||
case running:
|
||
out["engine_mode"] = "exploring"
|
||
default:
|
||
out["engine_mode"] = "idle"
|
||
}
|
||
|
||
out["active_task"] = map[string]any{
|
||
"id": t.ID, "description": t.Description, "goal": t.Goal,
|
||
"running": running,
|
||
"paused": paused,
|
||
"in_flight": inFlight,
|
||
"last_activity": last,
|
||
"stalled": stalled,
|
||
"goals_total": len(goals),
|
||
"goals_met": goalsMet,
|
||
}
|
||
writeJSON(w, 200, out)
|
||
}
|
||
|
||
func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) {
|
||
active := ""
|
||
if t := s.m.ActiveTask(); t != nil {
|
||
active = t.ID
|
||
}
|
||
list := s.m.List()
|
||
toks, _ := s.m.PG().TokenTotalsAll() // whole-task token totals, one query for all tasks
|
||
lastAct, _ := s.m.PG().LastActivityAll() // persisted last-activity per task, one query
|
||
goalCounts, _ := s.m.PG().GoalCountsAll() // goal progress per exploration, one query
|
||
dtos := make([]TaskDTO, 0, len(list))
|
||
for _, t := range list {
|
||
status := "created"
|
||
switch {
|
||
case isTerminalStatus(t.Status): // 持久化终态优先(done/failed/timeout)
|
||
status = t.Status
|
||
case t.Paused || s.engine.IsPaused(t.ID):
|
||
status = "paused"
|
||
case s.engine.Ready() && s.engine.Started(t.ID):
|
||
status = "running"
|
||
}
|
||
dto := taskDTO(t, status)
|
||
dto.Tokens = tokenTotalDTO(toks[t.ExpID])
|
||
// prefer the live in-memory heartbeat (fresher) and fall back to the
|
||
// persisted max activity time (survives restarts) for run-duration display.
|
||
dto.LastActivity = lastAct[t.ExpID]
|
||
if live := s.engine.LastActivity(t.ID); live > dto.LastActivity {
|
||
dto.LastActivity = live
|
||
}
|
||
gc := goalCounts[t.ExpID]
|
||
dto.GoalsTotal = gc.Total
|
||
dto.GoalsMet = gc.Met
|
||
dtos = append(dtos, dto)
|
||
}
|
||
writeJSON(w, 200, map[string]any{"tasks": dtos, "active": active})
|
||
}
|
||
|
||
func (s *Server) setActive(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
ID string `json:"id"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if !s.m.SetActive(req.ID) {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
// resume the engine for the opened task (idempotent — no-op if already running).
|
||
if t, ok := s.m.Task(req.ID); ok {
|
||
s.engine.Run(s.ctx, t)
|
||
}
|
||
writeJSON(w, 200, map[string]any{"active": req.ID})
|
||
}
|
||
|
||
// control pauses/resumes a task's autonomous execution (planner + workers).
|
||
func (s *Server) control(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
var req struct {
|
||
Action string `json:"action"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
switch req.Action {
|
||
case "pause":
|
||
s.engine.Pause(t.ID)
|
||
case "resume":
|
||
s.engine.Run(s.ctx, t) // ensure loops are alive, then un-pause + nudge
|
||
s.engine.Resume(t)
|
||
default:
|
||
writeErr(w, 400, "action must be pause|resume")
|
||
return
|
||
}
|
||
paused := s.engine.IsPaused(t.ID)
|
||
t.Paused = paused // keep the in-memory task (shown in /api/tasks) in sync
|
||
if err := s.m.SetTaskPaused(t.ID, paused); err != nil { // persist (survives restart)
|
||
log.Printf("[control] persist paused %s: %v", t.ID, err)
|
||
}
|
||
log.Printf("[task] #%s %s", t.ID, map[bool]string{true: "已暂停", false: "已恢复"}[paused])
|
||
writeJSON(w, 200, map[string]any{"id": t.ID, "paused": paused})
|
||
}
|
||
|
||
// rerunIntent 重跑一条没跑成功的意图(blocked/exhausted/stopped):把它置回 open,worker
|
||
// 会重新认领、从头再跑(已写回图谱的 fact/finding/asset 保留);若任务已终态/暂停则顺带复活。
|
||
// 用于「出错的 work 点击继续运行」——网络/LLM 抖动导致 blocked 后可一键重试。
|
||
func (s *Server) rerunIntent(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
iid, err := strconv.ParseInt(r.PathValue("iid"), 10, 64)
|
||
if err != nil {
|
||
writeErr(w, 400, "bad intent id")
|
||
return
|
||
}
|
||
reopened, err := t.Store.ReopenIntent(iid)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if !reopened {
|
||
writeErr(w, 409, "该意图不是可重跑状态(仅 blocked/exhausted/stopped 可重跑)")
|
||
return
|
||
}
|
||
s.reviveTask(t) // 终态/暂停 → 拉回 running,确保 worker 循环存活并重新认领
|
||
log.Printf("[task] #%s 意图 #%d 已重开(重跑)", t.ID, iid)
|
||
writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": iid})
|
||
}
|
||
|
||
// rerunBlocked 批量重跑本任务全部 blocked 意图(适合一次网络/LLM 断连导致多条 blocked 后
|
||
// 一键全部重试),置回 open 并复活任务;返回重开的条数。
|
||
func (s *Server) rerunBlocked(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
n, err := t.Store.ReopenBlockedIntents()
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if n > 0 {
|
||
s.reviveTask(t)
|
||
log.Printf("[task] #%s 批量重开 %d 条 blocked 意图", t.ID, n)
|
||
}
|
||
writeJSON(w, 200, map[string]any{"id": t.ID, "reopened": n})
|
||
}
|
||
|
||
// getLLM returns the current LLM config (key never exposed).
|
||
func (s *Server) getLLM(w http.ResponseWriter, r *http.Request) {
|
||
s.cfgMu.Lock()
|
||
defer s.cfgMu.Unlock()
|
||
writeJSON(w, 200, map[string]any{
|
||
"configured": s.llmOn,
|
||
"provider": s.llmCfg.Provider(),
|
||
"model": s.llmCfg.Model,
|
||
"base_url": s.llmCfg.BaseURL,
|
||
"proxy": s.llmCfg.Proxy,
|
||
"key_set": s.llmCfg.APIKey != "",
|
||
"rate_per_second": s.llmCfg.RatePerSecond,
|
||
"rate_per_minute": s.llmCfg.RatePerMinute,
|
||
"context_window_k": s.llmCfg.ContextWindowK,
|
||
"reasoning_effort": s.llmCfg.ReasoningEffort,
|
||
})
|
||
}
|
||
|
||
// setLLM configures the LLM at runtime. A blank api_key keeps the existing key.
|
||
func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Provider string `json:"provider"`
|
||
Model string `json:"model"`
|
||
BaseURL string `json:"base_url"`
|
||
Proxy string `json:"proxy"`
|
||
APIKey string `json:"api_key"`
|
||
RatePerSecond float64 `json:"rate_per_second"`
|
||
RatePerMinute float64 `json:"rate_per_minute"`
|
||
ContextWindowK int `json:"context_window_k"`
|
||
ReasoningEffort string `json:"reasoning_effort"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||
cfg.RatePerSecond, cfg.RatePerMinute = req.RatePerSecond, req.RatePerMinute
|
||
cfg.ReasoningEffort = req.ReasoningEffort
|
||
if k := req.ContextWindowK; k > 0 { // 0 = keep default (200K); cap at 1M
|
||
if k > 1000 {
|
||
k = 1000
|
||
}
|
||
cfg.ContextWindowK = k
|
||
}
|
||
if cfg.APIKey == "" {
|
||
s.cfgMu.Lock()
|
||
cfg.APIKey = s.llmCfg.APIKey // keep existing key if not re-entered
|
||
s.cfgMu.Unlock()
|
||
}
|
||
if cfg.APIKey == "" {
|
||
writeErr(w, 400, "api_key required")
|
||
return
|
||
}
|
||
if err := s.applyLLM(cfg); err != nil {
|
||
writeErr(w, 400, "provider init failed: "+err.Error())
|
||
return
|
||
}
|
||
s.saveLLMConfig(cfg) // persist to DB
|
||
log.Printf("[engine] LLM configured via UI: %s / %s", cfg.Provider(), cfg.Model)
|
||
s.getLLM(w, r)
|
||
}
|
||
|
||
// testLLM makes a real minimal completion to verify the config works.
|
||
func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Provider string `json:"provider"`
|
||
Model string `json:"model"`
|
||
BaseURL string `json:"base_url"`
|
||
Proxy string `json:"proxy"`
|
||
APIKey string `json:"api_key"`
|
||
ReasoningEffort string `json:"reasoning_effort"`
|
||
ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||
// mirror production: send the SAME thinking params so a provider that rejects the
|
||
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
|
||
cfg.ReasoningEffort = req.ReasoningEffort
|
||
// API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。
|
||
// 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。
|
||
if cfg.APIKey == "" && req.ProfileID != nil {
|
||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||
cfg.APIKey = p.APIKey
|
||
}
|
||
}
|
||
if cfg.APIKey == "" {
|
||
s.cfgMu.Lock()
|
||
cfg.APIKey = s.llmCfg.APIKey
|
||
s.cfgMu.Unlock()
|
||
}
|
||
if cfg.APIKey == "" {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"})
|
||
return
|
||
}
|
||
lat, err := agent.TestConnection(r.Context(), cfg)
|
||
if err != nil {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model})
|
||
}
|
||
|
||
type createTaskReq struct {
|
||
Description string `json:"description"`
|
||
Goal string `json:"goal"`
|
||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
|
||
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
|
||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
|
||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
|
||
}
|
||
|
||
func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||
var req createTaskReq
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, "bad json: "+err.Error())
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Description) == "" {
|
||
req.Description = "未命名任务"
|
||
}
|
||
// validate the pinned profile up front (must exist + be usable), so a bad id fails
|
||
// task creation instead of silently falling back to the active profile at run time.
|
||
if req.LLMProfileID != nil {
|
||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||
writeErr(w, 400, "所选 LLM 配置不存在或未设置 API Key")
|
||
return
|
||
}
|
||
}
|
||
if req.TimeoutSeconds < 0 {
|
||
req.TimeoutSeconds = 0
|
||
}
|
||
t, err := s.m.CreateTask(req.Description, req.Goal, req.LLMProfileID, req.TimeoutSeconds, req.PlanHeartbeatSeconds)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
log.Printf("[task] 新建任务 #%s «%s» 目标: %s", t.ID, req.Description, req.Goal)
|
||
// 共享的建后流程(seed + 种子意图 + 后台目标分解 + engine.Run),与 spawn_task 复用同一段。
|
||
// launchTask 内部异步,不阻塞 UI —— 目标分解在后台可见地进行。
|
||
s.launchTask(t, req.Description+" "+req.Goal, req.SeedFirstIntent != nil && *req.SeedFirstIntent)
|
||
writeJSON(w, 201, t)
|
||
}
|
||
|
||
var (
|
||
reURL = regexp.MustCompile(`https?://[^\s'"]+`)
|
||
reIPPort = regexp.MustCompile(`\b((?:\d{1,3}\.){3}\d{1,3})(?::(\d{1,5}))?`)
|
||
reDomain = regexp.MustCompile(`\b((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::(\d{1,5}))?`)
|
||
)
|
||
|
||
// parseTarget extracts a target (scheme, host, port) from free text, supporting
|
||
// full URLs, IP[:port] and domain[:port]. ok=false when nothing parseable.
|
||
func parseTarget(text string) (scheme, host string, port int, ok bool) {
|
||
text = strings.TrimSpace(text)
|
||
if m := reURL.FindString(text); m != "" {
|
||
if u, err := url.Parse(m); err == nil && u.Hostname() != "" {
|
||
scheme = strings.ToLower(u.Scheme)
|
||
host = strings.ToLower(u.Hostname())
|
||
port = portOr(u.Port(), defaultPort(scheme))
|
||
return scheme, host, port, true
|
||
}
|
||
}
|
||
if m := reIPPort.FindStringSubmatch(text); m != nil {
|
||
host = m[1]
|
||
port = portOr(m[2], 80)
|
||
return schemeForPort(port), host, port, true
|
||
}
|
||
if m := reDomain.FindStringSubmatch(text); m != nil {
|
||
host = strings.ToLower(m[1])
|
||
if m[2] == "" {
|
||
return "https", host, 443, true
|
||
}
|
||
port = portOr(m[2], 443)
|
||
return schemeForPort(port), host, port, true
|
||
}
|
||
return "", "", 0, false
|
||
}
|
||
|
||
func portOr(s string, d int) int {
|
||
if n, err := strconv.Atoi(s); err == nil && n > 0 {
|
||
return n
|
||
}
|
||
return d
|
||
}
|
||
func defaultPort(scheme string) int {
|
||
if scheme == "http" {
|
||
return 80
|
||
}
|
||
return 443
|
||
}
|
||
func schemeForPort(p int) string {
|
||
if p == 443 || p == 8443 {
|
||
return "https"
|
||
}
|
||
return "http"
|
||
}
|
||
|
||
// llmHost returns the host of the configured LLM endpoint (to keep it out of scope).
|
||
func (s *Server) llmHost() string {
|
||
s.cfgMu.Lock()
|
||
base := s.llmCfg.BaseURL
|
||
s.cfgMu.Unlock()
|
||
if base == "" {
|
||
return ""
|
||
}
|
||
if u, err := url.Parse(base); err == nil {
|
||
return strings.ToLower(u.Hostname())
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (s *Server) seed(t *Task, text string) {
|
||
scheme, host, port, ok := parseTarget(text)
|
||
if !ok {
|
||
log.Printf("[seed] task %s: 未能从 %q 解析出目标 host/IP,不创建站点(请手动配置 scope)", t.ID, text)
|
||
return
|
||
}
|
||
// P0-1 guard: never treat the configured LLM gateway as a target.
|
||
if gw := s.llmHost(); gw != "" && host == gw {
|
||
log.Printf("[seed] task %s: 目标 %q 是 LLM 网关,拒绝作为渗透目标", t.ID, host)
|
||
return
|
||
}
|
||
|
||
u := scheme + "://" + host
|
||
if !(scheme == "https" && port == 443) && !(scheme == "http" && port == 80) {
|
||
u += ":" + strconv.Itoa(port)
|
||
}
|
||
var rootID int64
|
||
if as := s.m.Assets(); as != nil {
|
||
if net.ParseIP(host) != nil {
|
||
rootID, _ = as.UpsertIP(db.UpsertIPReq{IP: host})
|
||
} else if scheme == "https" || scheme == "http" {
|
||
rootID, _ = as.UpsertHTTPService(db.UpsertHTTPServiceReq{URL: u})
|
||
} else {
|
||
rootID, _ = as.UpsertRootDomain(db.UpsertRootDomainReq{Domain: host})
|
||
}
|
||
}
|
||
// anchor the seeded assets to this task's begin root as lineage/provenance
|
||
// (the asset graph is global and shared; anchoring no longer gates reads).
|
||
if rootID > 0 {
|
||
if begin, _ := t.Store.OriginFactID(); begin > 0 {
|
||
_ = t.Store.Anchor(begin, rootID)
|
||
}
|
||
}
|
||
log.Printf("[seed] task %s: 目标站点 %s", t.ID, u)
|
||
// 不在这里 Notify:首轮是否触发统一由 engine.Run 的 HasActiveIntent 决定(种子意图任务
|
||
// 跳过首轮)。seed 早于 Run 执行,若在此 Notify 会 buffered 到通道、被 plannerLoop 启动时
|
||
// 消费掉而绕过 Run 的门控 → 种子任务仍误触发首轮。
|
||
}
|
||
|
||
// seedFirstIntent writes ONE open intent (summary = 描述+目标) into the task's
|
||
// frontier at creation, so a worker can claim and run it immediately without first
|
||
// waiting a planner round. Mirrors a planner top-level intent: it links from the
|
||
// origin fact (RelDerivedFrom) so it still traces back to a fact node. Best-effort —
|
||
// a failure just falls back to the normal planner-driven flow.
|
||
func (s *Server) seedFirstIntent(t *Task) {
|
||
summary := fmt.Sprintf("完成任务目标:%s(任务:%s)", t.Goal, t.Description)
|
||
id, err := t.Store.AddIntent(map[string]any{"summary": summary}, 8, nil, "seed")
|
||
if err != nil {
|
||
log.Printf("[seed] task %s: 下发种子意图失败: %v", t.ID, err)
|
||
return
|
||
}
|
||
if origin, _ := t.Store.OriginFactID(); origin > 0 {
|
||
_ = t.Store.Link(origin, db.RelDerivedFrom, id)
|
||
}
|
||
log.Printf("[seed] task %s: 已下发种子意图 #%d", t.ID, id)
|
||
}
|
||
|
||
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
writeJSON(w, 200, t)
|
||
}
|
||
|
||
// taskCoverage returns a task's rough asset test coverage (denominator/tested/backlog).
|
||
func (s *Server) taskCoverage(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
as := s.m.Assets()
|
||
if as == nil {
|
||
writeErr(w, 503, "asset store 未启用")
|
||
return
|
||
}
|
||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||
cov, err := as.TaskCoverage(taskID, t.ExpID)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, cov)
|
||
}
|
||
|
||
// taskCoverageGraph returns the force-directed asset coverage graph for a task:
|
||
// all in-scope assets (每种类型) + 连接用的根域名/公司节点, each carrying tested/in_scope.
|
||
func (s *Server) taskCoverageGraph(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
as := s.m.Assets()
|
||
if as == nil {
|
||
writeErr(w, 503, "asset store 未启用")
|
||
return
|
||
}
|
||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||
g, err := as.BuildCoverageGraph(taskID, t.ExpID)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, g)
|
||
}
|
||
|
||
// taskAssetRefs returns the intents / facts / findings in this task anchored to a
|
||
// given asset id — powers the coverage-graph node drawer's「关联意图 / 关联事实」。
|
||
func (s *Server) taskAssetRefs(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
assetID, _ := strconv.ParseInt(r.URL.Query().Get("asset_id"), 10, 64)
|
||
if assetID <= 0 {
|
||
writeErr(w, 400, "需要 asset_id")
|
||
return
|
||
}
|
||
refs, err := t.Store.AssetRefs(assetID)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
intents := []db.AssetRef{}
|
||
facts := []db.AssetRef{}
|
||
findings := []db.AssetRef{}
|
||
for _, ref := range refs {
|
||
switch ref.Kind {
|
||
case "intent":
|
||
intents = append(intents, ref)
|
||
case "fact":
|
||
facts = append(facts, ref)
|
||
case "finding":
|
||
findings = append(findings, ref)
|
||
}
|
||
}
|
||
writeJSON(w, 200, map[string]any{"intents": intents, "facts": facts, "findings": findings})
|
||
}
|
||
|
||
// taskScopeList returns a task's scope rows (coverage denominator sources).
|
||
func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) {
|
||
t, ok := s.m.Task(r.PathValue("id"))
|
||
if !ok {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
as := s.m.Assets()
|
||
if as == nil {
|
||
writeErr(w, 503, "asset store 未启用")
|
||
return
|
||
}
|
||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||
rows, err := as.ListTaskScope(taskID)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"scope": rows})
|
||
}
|
||
|
||
func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
fr, _ := t.Store.Frontier(atoiDefault(r.URL.Query().Get("limit"), 100))
|
||
writeJSON(w, 200, taskNodeDTOs(fr))
|
||
}
|
||
|
||
func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
|
||
// 无 task 参数 → 全局「发现」页:从独立 findings 表读取(任务删除后 finding 依然保留)。
|
||
// 带 task 参数 → 仅该任务(任务概览/发现 Tab 用),从 exploration_nodes 读(任务在则节点在)。
|
||
q := r.URL.Query()
|
||
taskParam := q.Get("task")
|
||
if taskParam == "" {
|
||
// 带 page/limit → 服务端分页 {items,total,...};不带 → 裸数组(dashboard 汇总用,
|
||
// 与 intents 端点的兼容策略一致)。筛选/排序统一下推到 SQL。
|
||
if q.Get("page") == "" && q.Get("limit") == "" {
|
||
fs, _ := s.m.pg.ListFindings(500)
|
||
assets := s.resolveFindingAssets(fs)
|
||
out := make([]FindingDTO, 0, len(fs))
|
||
for _, f := range fs {
|
||
out = append(out, findingFromDB(f, assets))
|
||
}
|
||
writeJSON(w, 200, out)
|
||
return
|
||
}
|
||
page := atoiDefault(q.Get("page"), 1)
|
||
limit := min(atoiDefault(q.Get("limit"), 20), 200)
|
||
filter := db.FindingFilter{
|
||
Severity: normFilter(q.Get("severity")),
|
||
Status: normFilter(q.Get("status")),
|
||
VulnClass: normFilter(q.Get("vulnclass")),
|
||
Sort: q.Get("sort"),
|
||
}
|
||
fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
assets := s.resolveFindingAssets(fs)
|
||
out := make([]FindingDTO, 0, len(fs))
|
||
for _, f := range fs {
|
||
out = append(out, findingFromDB(f, assets))
|
||
}
|
||
writeJSON(w, 200, map[string]any{"items": out, "total": total, "page": page, "page_size": limit})
|
||
return
|
||
}
|
||
t := s.m.ResolveTask(taskParam)
|
||
if t == nil {
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
f, _ := t.Store.ListByKind(db.KindFinding, 200)
|
||
tid, _ := strconv.ParseInt(t.ID, 10, 64)
|
||
meta, err := s.m.pg.FindingMetaByNodeID(tid)
|
||
if err != nil {
|
||
log.Printf("[findings] task=%s meta: %v", t.ID, err)
|
||
}
|
||
var aidSet []int64
|
||
for _, m := range meta {
|
||
aidSet = append(aidSet, m.AssetIDs...)
|
||
}
|
||
assets := s.resolveAssetIDs(aidSet)
|
||
writeJSON(w, 200, findingDTOsForTask(t, f, meta, assets))
|
||
}
|
||
|
||
// normFilter maps the frontend's "all" sentinel (and empty) to "" so the DB layer
|
||
// treats it as no filter.
|
||
func normFilter(v string) string {
|
||
if v == "all" {
|
||
return ""
|
||
}
|
||
return v
|
||
}
|
||
|
||
// resolveFindingAssets loads every asset anchored by the given findings, keyed by
|
||
// id, so each finding DTO can render its assets' labels.
|
||
func (s *Server) resolveFindingAssets(fs []*db.DBFinding) map[int64]*db.Asset {
|
||
var ids []int64
|
||
for _, f := range fs {
|
||
ids = append(ids, f.AssetIDs...)
|
||
}
|
||
return s.resolveAssetIDs(ids)
|
||
}
|
||
|
||
// resolveAssetIDs de-dupes ids and loads their asset rows into an id→asset map.
|
||
func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset {
|
||
assets := map[int64]*db.Asset{}
|
||
seen := map[int64]bool{}
|
||
var uniq []int64
|
||
for _, id := range ids {
|
||
if id > 0 && !seen[id] {
|
||
seen[id] = true
|
||
uniq = append(uniq, id)
|
||
}
|
||
}
|
||
if len(uniq) == 0 {
|
||
return assets
|
||
}
|
||
list, err := s.m.pg.Assets().GetByIDs(uniq)
|
||
if err != nil {
|
||
log.Printf("[findings] resolve assets: %v", err)
|
||
}
|
||
for _, a := range list {
|
||
assets[a.ID] = a
|
||
}
|
||
return assets
|
||
}
|
||
|
||
// findingStats serves the whole-table aggregates (stat cards + vuln-class filter)
|
||
// for the paginated 发现 page.
|
||
func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
||
st, err := s.m.pg.FindingStats()
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, st)
|
||
}
|
||
|
||
// getFinding returns one finding by its standalone-table id (DTO finding_id),
|
||
// with anchored assets resolved for display.
|
||
func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
|
||
id := int64(atoiDefault(r.PathValue("id"), 0))
|
||
if id <= 0 {
|
||
writeErr(w, 400, "bad finding id")
|
||
return
|
||
}
|
||
f, err := s.m.pg.GetFinding(id)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if f == nil {
|
||
writeErr(w, 404, "finding not found")
|
||
return
|
||
}
|
||
assets := s.resolveAssetIDs(f.AssetIDs)
|
||
writeJSON(w, 200, findingFromDB(f, assets))
|
||
}
|
||
|
||
// patchFinding partially updates a finding: any subset of {status, severity}.
|
||
// The id is the standalone findings-table id (DTO finding_id). Severity edits are
|
||
// mirrored onto the originating exploration node so the per-task view stays in
|
||
// sync. Returns the updated finding DTO.
|
||
func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
|
||
id := int64(atoiDefault(r.PathValue("id"), 0))
|
||
if id <= 0 {
|
||
writeErr(w, 400, "bad finding id")
|
||
return
|
||
}
|
||
var body struct {
|
||
Status *string `json:"status"`
|
||
Severity *string `json:"severity"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeErr(w, 400, "bad json: "+err.Error())
|
||
return
|
||
}
|
||
if body.Status == nil && body.Severity == nil {
|
||
writeErr(w, 400, "nothing to update: provide status and/or severity")
|
||
return
|
||
}
|
||
if body.Status != nil {
|
||
if !db.ValidFindingStatus(*body.Status) {
|
||
writeErr(w, 400, "bad status: "+*body.Status)
|
||
return
|
||
}
|
||
n, err := s.m.pg.SetFindingStatus(id, *body.Status)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if n == 0 {
|
||
writeErr(w, 404, "finding not found")
|
||
return
|
||
}
|
||
}
|
||
if body.Severity != nil {
|
||
if !db.ValidSeverity(*body.Severity) {
|
||
writeErr(w, 400, "bad severity: "+*body.Severity)
|
||
return
|
||
}
|
||
n, err := s.m.pg.SetFindingSeverity(id, *body.Severity)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if n == 0 {
|
||
writeErr(w, 404, "finding not found")
|
||
return
|
||
}
|
||
}
|
||
f, err := s.m.pg.GetFinding(id)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if f == nil {
|
||
writeErr(w, 404, "finding not found")
|
||
return
|
||
}
|
||
writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs)))
|
||
}
|
||
|
||
func (s *Server) intents(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
// Back-compat: bare list shape when the task can't be resolved.
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
q := r.URL.Query()
|
||
limit := min(atoiDefault(q.Get("limit"), 300), 500)
|
||
// No paging params → preserve the legacy bare-array response so existing callers
|
||
// (and the poll) keep working unchanged.
|
||
if q.Get("before") == "" && q.Get("page") == "" {
|
||
in, err := t.Store.ListByKind(db.KindIntent, limit)
|
||
if err != nil {
|
||
log.Printf("[intents] task=%s limit=%d: %v", t.ID, limit, err)
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, taskNodeDTOs(in))
|
||
return
|
||
}
|
||
// Paged form: ?before=<id> (or ?page as a marker) → {items, has_more} so the
|
||
// worker session list can reach past the old fixed 300 boundary on scroll.
|
||
before := int64(atoiDefault(q.Get("before"), 0))
|
||
in, hasMore, err := t.Store.ListByKindPage(db.KindIntent, before, limit)
|
||
if err != nil {
|
||
log.Printf("[intents] task=%s before=%d limit=%d: %v", t.ID, before, limit, err)
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"items": taskNodeDTOs(in), "has_more": hasMore})
|
||
}
|
||
|
||
// explorationGraph returns the whole exploration chain (task graph) as nodes+edges.
|
||
func (s *Server) explorationGraph(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeJSON(w, 200, map[string]any{"nodes": []any{}, "edges": []any{}})
|
||
return
|
||
}
|
||
nodes, _ := t.Store.Nodes(2000)
|
||
edges, _ := t.Store.Edges(5000)
|
||
writeJSON(w, 200, map[string]any{"nodes": taskNodeDTOs(nodes), "edges": edgeDTOs(edges)})
|
||
}
|
||
|
||
// activity returns the worker execution step log (incremental via ?since=seq).
|
||
func (s *Server) activity(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeJSON(w, 200, map[string]any{"items": []any{}, "cursor": 0})
|
||
return
|
||
}
|
||
since := int64(atoiDefault(r.URL.Query().Get("since"), 0))
|
||
limit := atoiDefault(r.URL.Query().Get("limit"), 300)
|
||
var intentPtr *int64
|
||
if iv := r.URL.Query().Get("intent"); iv != "" {
|
||
if n, err := strconv.ParseInt(iv, 10, 64); err == nil {
|
||
intentPtr = &n
|
||
}
|
||
}
|
||
items, cursor, err := t.Store.ActivityList(intentPtr, since, limit)
|
||
if err != nil {
|
||
log.Printf("[activity] task=%s since=%d limit=%d intent=%v: %v", t.ID, since, limit, intentPtr, err)
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor})
|
||
}
|
||
|
||
// parseActivitySession maps a stable session key (main | plan | intent:<ID>) to a
|
||
// DB session filter. Goal Agent + Planner both live under worker="planner" (the
|
||
// single Plan session); a Worker session is one intent, keyed by its node id.
|
||
func parseActivitySession(sess string) (db.ActivitySessionFilter, bool) {
|
||
switch {
|
||
case sess == "" || sess == "main":
|
||
return db.ActivitySessionFilter{Worker: "mainagent"}, true
|
||
case sess == "plan":
|
||
return db.ActivitySessionFilter{Worker: "planner"}, true
|
||
case strings.HasPrefix(sess, "intent:"):
|
||
id, err := strconv.ParseInt(strings.TrimPrefix(sess, "intent:"), 10, 64)
|
||
if err != nil {
|
||
return db.ActivitySessionFilter{}, false
|
||
}
|
||
return db.ActivitySessionFilter{NodeID: &id}, true
|
||
}
|
||
return db.ActivitySessionFilter{}, false
|
||
}
|
||
|
||
// activityHistory serves one reverse-paginated page of a session's activity history.
|
||
// The latest page (no ?before) opens a session; ?before=<id> pulls the older page on
|
||
// scroll-up. snapshot_cursor is the TASK-level max id at query time — the client uses
|
||
// it to open the single task SSE at since=snapshot_cursor so history (id<=cursor) and
|
||
// the live tail (id>cursor) meet with no gap and no overlap.
|
||
func (s *Server) activityHistory(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
q := r.URL.Query()
|
||
sess := q.Get("session")
|
||
filter, ok := parseActivitySession(sess)
|
||
if !ok {
|
||
writeErr(w, 400, "bad session")
|
||
return
|
||
}
|
||
before := int64(atoiDefault(q.Get("before"), 0))
|
||
limit := min(atoiDefault(q.Get("limit"), 200), 500) // cap so one request can't pull an unbounded slice
|
||
snapshot, err := t.Store.ActivityMaxID()
|
||
if err != nil {
|
||
log.Printf("[activity/history] task=%s session=%s snapshot: %v", t.ID, sess, err)
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
items, hasMore, err := t.Store.ActivityPage(filter, before, limit)
|
||
if err != nil {
|
||
log.Printf("[activity/history] task=%s session=%s before=%d limit=%d: %v", t.ID, sess, before, limit, err)
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
earliest := before
|
||
if len(items) > 0 {
|
||
earliest = items[0].ID
|
||
}
|
||
writeJSON(w, 200, map[string]any{
|
||
"items": activityDTOs(items),
|
||
"snapshot_cursor": snapshot,
|
||
"earliest_cursor": earliest,
|
||
"has_more": hasMore,
|
||
})
|
||
}
|
||
|
||
// tokenStats returns per-worker token usage (input/output/cache read/write) for a
|
||
// task — main agent, planner, and each work#N.
|
||
func (s *Server) tokenStats(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeJSON(w, 200, map[string]any{"workers": []any{}})
|
||
return
|
||
}
|
||
stats, err := t.Store.TokenStatsByWorker()
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
total, _ := t.Store.TokenTotal() // whole-task total (all agents)
|
||
writeJSON(w, 200, map[string]any{"workers": stats, "total": tokenTotalDTO(total)})
|
||
}
|
||
|
||
// tokenDailyStats returns global token consumption aggregated by calendar day
|
||
// (UTC) across all tasks for the past ?days=N days (default 30).
|
||
func (s *Server) tokenDailyStats(w http.ResponseWriter, r *http.Request) {
|
||
days := atoiDefault(r.URL.Query().Get("days"), 30)
|
||
if s.m.pg == nil {
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
buckets, err := s.m.pg.TokenDailyAll(days)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
if buckets == nil {
|
||
buckets = []db.DailyTokenBucket{}
|
||
}
|
||
writeJSON(w, 200, buckets)
|
||
}
|
||
|
||
// conversationTokens returns per-conversation token summaries so the dashboard can
|
||
// merge chat (conversation) usage into its per-profile / daily token stats — which
|
||
// otherwise count only task (exploration) usage.
|
||
func (s *Server) conversationTokens(w http.ResponseWriter, r *http.Request) {
|
||
if s.m.pg == nil {
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
rows, err := s.m.pg.ConversationTokenSummaries()
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, rows)
|
||
}
|
||
|
||
// streamActivity is the live SSE tail: it replays history after ?since=<seq>, then
|
||
// pushes each newly appended activity for the task. The seq cursor makes history +
|
||
// live join gap-free; on reconnect the client passes its last seq to catch any
|
||
// dropped events. Optional ?intent=<id> scopes the stream to one worker session.
|
||
// getLogs returns recent backend log lines (Seq > since), newest-last.
|
||
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||
since := int64(atoiDefault(r.URL.Query().Get("since"), 0))
|
||
limit := atoiDefault(r.URL.Query().Get("limit"), 500)
|
||
lines, cursor := logSink.recent(since, limit)
|
||
writeJSON(w, 200, map[string]any{"items": lines, "cursor": cursor})
|
||
}
|
||
|
||
// getLogsHistory returns older log lines from the DB (before a given db_id).
|
||
// GET /api/logs/history?before=<db_id>&limit=200
|
||
// Returns {items:[LogLine], has_more: bool}.
|
||
func (s *Server) getLogsHistory(w http.ResponseWriter, r *http.Request) {
|
||
if s.m.pg == nil {
|
||
writeJSON(w, 200, map[string]any{"items": []any{}, "has_more": false})
|
||
return
|
||
}
|
||
before := int64(atoiDefault(r.URL.Query().Get("before"), 0))
|
||
limit := atoiDefault(r.URL.Query().Get("limit"), 200)
|
||
if limit > 500 {
|
||
limit = 500
|
||
}
|
||
// If no before given, return the most recent DB rows (mirrors ring restore).
|
||
var (
|
||
rows []*db.DBLog
|
||
err error
|
||
)
|
||
if before <= 0 {
|
||
rows, err = s.m.pg.RecentLogs(limit)
|
||
} else {
|
||
rows, err = s.m.pg.ListLogsBefore(before, limit)
|
||
}
|
||
if err != nil {
|
||
writeErr(w, 500, "db: "+err.Error())
|
||
return
|
||
}
|
||
items := make([]LogLine, 0, len(rows))
|
||
for _, r := range rows {
|
||
items = append(items, LogLine{
|
||
DBID: r.ID,
|
||
TS: r.CreatedAt.Format(time.RFC3339),
|
||
Level: r.Level,
|
||
Tag: r.Tag,
|
||
Text: r.Text,
|
||
})
|
||
}
|
||
writeJSON(w, 200, map[string]any{"items": items, "has_more": len(rows) == limit})
|
||
}
|
||
|
||
// streamLogs is the live SSE tail of the backend log: replays history after
|
||
// ?since=<seq>, then pushes each new line.
|
||
func (s *Server) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||
flusher, ok := w.(http.Flusher)
|
||
if !ok {
|
||
writeErr(w, 500, "streaming unsupported")
|
||
return
|
||
}
|
||
since := int64(atoiDefault(r.URL.Query().Get("since"), 0))
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
w.Header().Set("Connection", "keep-alive")
|
||
w.Header().Set("X-Accel-Buffering", "no")
|
||
|
||
ch, unsub := logSink.subscribe()
|
||
defer unsub()
|
||
send := func(l LogLine) {
|
||
b, _ := json.Marshal(l)
|
||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||
flusher.Flush()
|
||
}
|
||
lines, cursor := logSink.recent(since, 1000)
|
||
for _, l := range lines {
|
||
send(l)
|
||
}
|
||
if cursor > since {
|
||
since = cursor
|
||
}
|
||
flusher.Flush()
|
||
|
||
ctx := r.Context()
|
||
ping := time.NewTicker(20 * time.Second)
|
||
defer ping.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case l, ok := <-ch:
|
||
if !ok {
|
||
return
|
||
}
|
||
if l.Seq <= since {
|
||
continue
|
||
}
|
||
since = l.Seq
|
||
send(l)
|
||
case <-ping.C:
|
||
fmt.Fprint(w, ": ping\n\n")
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Server) streamActivity(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
flusher, ok := w.(http.Flusher)
|
||
if !ok {
|
||
writeErr(w, 500, "streaming unsupported")
|
||
return
|
||
}
|
||
var intentPtr *int64
|
||
if iv := r.URL.Query().Get("intent"); iv != "" {
|
||
if n, err := strconv.ParseInt(iv, 10, 64); err == nil {
|
||
intentPtr = &n
|
||
}
|
||
}
|
||
// Cursor precedence: the browser's automatic reconnect sends Last-Event-ID (the
|
||
// last id it received) — trust it over the query so an auto-reconnect resumes
|
||
// exactly where it dropped. A fresh/manual connect has no header and passes
|
||
// since=<snapshot_cursor> from the history page instead.
|
||
since := int64(atoiDefault(r.URL.Query().Get("since"), 0))
|
||
if le := r.Header.Get("Last-Event-ID"); le != "" {
|
||
if n, err := strconv.ParseInt(le, 10, 64); err == nil {
|
||
since = n
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
w.Header().Set("Connection", "keep-alive")
|
||
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering
|
||
|
||
// Subscribe BEFORE replaying history so events in between aren't lost; dedup the
|
||
// overlap by skipping channel events whose id was already replayed.
|
||
ch, unsub := s.engine.Broadcaster().Subscribe(t.ID)
|
||
defer unsub()
|
||
|
||
// Emit a standard SSE id: line so the browser echoes it as Last-Event-ID on
|
||
// auto-reconnect (see cursor precedence above).
|
||
sendSSE := func(a db.Activity) {
|
||
b, _ := json.Marshal(activityDTO(a))
|
||
fmt.Fprintf(w, "id: %d\ndata: %s\n\n", a.ID, b)
|
||
flusher.Flush()
|
||
}
|
||
|
||
// Compensate the DB backlog after `since` in batches until caught up. This is the
|
||
// gap between the history snapshot and the live tail — NOT the first-page history
|
||
// (that's the /activity/history endpoint). A long task can have far more than one
|
||
// batch, so loop instead of a single fixed read; on a query error log it and close
|
||
// so the client reconnects and retries from its last id (broadcast is lossy — the
|
||
// DB is the source of truth). The Broadcaster keeps buffering live events meanwhile;
|
||
// the id<=since skip below drops any that this replay already covered.
|
||
const replayBatch = 500
|
||
for {
|
||
items, cursor, err := t.Store.ActivityList(intentPtr, since, replayBatch)
|
||
if err != nil {
|
||
log.Printf("[activity/stream] task=%s replay since=%d: %v", t.ID, since, err)
|
||
return
|
||
}
|
||
for _, a := range items {
|
||
sendSSE(a)
|
||
}
|
||
if cursor > since {
|
||
since = cursor
|
||
}
|
||
if len(items) < replayBatch {
|
||
break
|
||
}
|
||
}
|
||
flusher.Flush()
|
||
|
||
ctx := r.Context()
|
||
ping := time.NewTicker(20 * time.Second)
|
||
defer ping.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case a, ok := <-ch:
|
||
if !ok {
|
||
return
|
||
}
|
||
if a.ID <= since {
|
||
continue // already replayed
|
||
}
|
||
if intentPtr != nil && (a.NodeID == nil || *a.NodeID != *intentPtr) {
|
||
continue // scoped session: only this intent's steps
|
||
}
|
||
since = a.ID
|
||
sendSSE(a)
|
||
case <-ping.C:
|
||
fmt.Fprint(w, ": ping\n\n")
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
// activityDetail lazily returns the full detail blob for one step.
|
||
func (s *Server) activityDetail(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeErr(w, 404, "no task")
|
||
return
|
||
}
|
||
seq, _ := strconv.ParseInt(r.PathValue("seq"), 10, 64)
|
||
d, err := t.Store.ActivityDetail(seq)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"detail": d})
|
||
}
|
||
|
||
func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) {
|
||
tr := s.m.Traffic()
|
||
if tr == nil {
|
||
writeJSON(w, 200, map[string]any{"enabled": false, "exchanges": []any{}})
|
||
return
|
||
}
|
||
q := r.URL.Query()
|
||
page := atoiDefault(q.Get("page"), 0)
|
||
size := atoiDefault(q.Get("size"), 100)
|
||
ex, matched, _ := tr.Page(q.Get("host"), q.Get("method"), q.Get("q"), page, size)
|
||
count, _ := tr.Count() // global total, for the stat card
|
||
writeJSON(w, 200, map[string]any{
|
||
"enabled": s.m.TrafficEnabled(), // reflect the capture toggle
|
||
"proxy": s.m.ProxyAddr(),
|
||
"count": count, // total recorded (unfiltered)
|
||
"total": matched, // rows matching the current filter (for pagination)
|
||
"page": page,
|
||
"size": size,
|
||
"exchanges": trafficDTOs(ex),
|
||
})
|
||
}
|
||
|
||
// getTrafficHosts returns distinct recorded hosts with counts, for the page's
|
||
// target picker (pick a host → filter the list, then delete it).
|
||
func (s *Server) getTrafficHosts(w http.ResponseWriter, r *http.Request) {
|
||
tr := s.m.Traffic()
|
||
if tr == nil {
|
||
writeJSON(w, 200, map[string]any{"hosts": []any{}})
|
||
return
|
||
}
|
||
hosts, err := tr.Hosts()
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"hosts": hosts})
|
||
}
|
||
|
||
// deleteTraffic removes recorded traffic for every host containing the query's
|
||
// host substring (the page's host filter is substring-based, so what you
|
||
// filtered is what gets deleted): index rows + each host's file tree, then
|
||
// garbage-collects blobs no remaining exchange references. Empty host → 400.
|
||
// Returns the number of exchanges deleted.
|
||
func (s *Server) deleteTraffic(w http.ResponseWriter, r *http.Request) {
|
||
tr := s.m.Traffic()
|
||
if tr == nil {
|
||
writeErr(w, 404, "traffic disabled")
|
||
return
|
||
}
|
||
host := strings.TrimSpace(r.URL.Query().Get("host"))
|
||
if host == "" {
|
||
writeErr(w, 400, "missing host")
|
||
return
|
||
}
|
||
n, err := tr.DeleteHost(host)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"deleted": n})
|
||
}
|
||
|
||
// deleteTrafficHosts removes traffic for a set of EXACT hosts (JSON body
|
||
// {"hosts": [...]}) — the batch path for the page's multi-select delete. Exact
|
||
// match, so picking "api.example.com" never sweeps "api.example.com.cn".
|
||
// Returns the number of exchanges deleted.
|
||
func (s *Server) deleteTrafficHosts(w http.ResponseWriter, r *http.Request) {
|
||
tr := s.m.Traffic()
|
||
if tr == nil {
|
||
writeErr(w, 404, "traffic disabled")
|
||
return
|
||
}
|
||
var req struct {
|
||
Hosts []string `json:"hosts"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, "invalid body")
|
||
return
|
||
}
|
||
hosts := make([]string, 0, len(req.Hosts))
|
||
for _, h := range req.Hosts {
|
||
if h = strings.TrimSpace(h); h != "" {
|
||
hosts = append(hosts, h)
|
||
}
|
||
}
|
||
if len(hosts) == 0 {
|
||
writeErr(w, 400, "missing hosts")
|
||
return
|
||
}
|
||
n, err := tr.DeleteHostsExact(hosts)
|
||
if err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"deleted": n})
|
||
}
|
||
|
||
// getTrafficExchange returns the full raw request/response of one exchange,
|
||
// read on demand from the traffic tree (bodies are not in the paged list).
|
||
func (s *Server) getTrafficExchange(w http.ResponseWriter, r *http.Request) {
|
||
tr := s.m.Traffic()
|
||
if tr == nil {
|
||
writeErr(w, 404, "traffic disabled")
|
||
return
|
||
}
|
||
id := r.URL.Query().Get("id")
|
||
if id == "" {
|
||
writeErr(w, 400, "missing id")
|
||
return
|
||
}
|
||
req, resp, err := tr.Get(id)
|
||
if err != nil {
|
||
writeErr(w, 404, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"req": req, "resp": resp})
|
||
}
|
||
|
||
// getSettings returns the runtime app settings the UI toggles. The Brave API key
|
||
// is returned as a boolean presence flag (brave_key_set), never the value itself,
|
||
// so the UI can show "configured" without echoing the secret back.
|
||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, 200, s.settingsPayload())
|
||
}
|
||
|
||
func (s *Server) settingsPayload() map[string]any {
|
||
on, backend, braveKey, tavilyKey, proxy := s.m.WebSearch()
|
||
pyStored, _, _ := s.m.pg.GetSetting(settingPythonInterp)
|
||
return map[string]any{
|
||
"traffic_capture": s.m.TrafficEnabled(),
|
||
"llm_record": s.m.LLMRecordEnabled(),
|
||
"web_search_enabled": on,
|
||
"web_search_backend": backend,
|
||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||
}
|
||
}
|
||
|
||
// pgDetectPython re-runs interpreter detection, stores + returns it.
|
||
func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
|
||
p := detectPython()
|
||
if p == "" {
|
||
writeErr(w, 404, "未检测到 python(python3/python 均不在 PATH)")
|
||
return
|
||
}
|
||
if err := s.m.pg.SetSetting(settingPythonInterp, p); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"python_interpreter": p})
|
||
}
|
||
|
||
// putSettings applies a settings change. Toggling traffic_capture rebuilds the
|
||
// agents (applyLLM) so the new proxy/traffic-tools/prompt state takes hold — when
|
||
// off, agents get no proxy config, no traffic tools, and no proxy prompt content.
|
||
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
TrafficCapture *bool `json:"traffic_capture"`
|
||
LLMRecord *bool `json:"llm_record"` // LLM 录制开关(默认关);即时生效,无需重建 agent
|
||
// Web search. WebSearchEnabled/Backend toggle the tool + backend; BraveKey/TavilyKey
|
||
// are optional — omit (null) to leave a stored key untouched, send "" to clear.
|
||
WebSearchEnabled *bool `json:"web_search_enabled"`
|
||
WebSearchBackend *string `json:"web_search_backend"`
|
||
BraveKey *string `json:"brave_search_api_key"`
|
||
TavilyKey *string `json:"tavily_search_api_key"`
|
||
WebSearchProxy *string `json:"web_search_proxy"` // 独立出口代理(http/https/socks5);null=不改,""=清空
|
||
PythonInterp *string `json:"python_interpreter"` // 自定义脚本工具的 python 解释器路径
|
||
Workers *int `json:"workers"` // 并发工作 agent 数(>0);对之后启动的任务生效
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
if req.Workers != nil {
|
||
if err := s.m.SetWorkers(*req.Workers); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
}
|
||
if req.PythonInterp != nil {
|
||
if err := s.m.pg.SetSetting(settingPythonInterp, strings.TrimSpace(*req.PythonInterp)); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
}
|
||
if req.LLMRecord != nil {
|
||
// 录制器每次调用读取该标志,切换即时生效,无需 applyLLM 重建。
|
||
if err := s.m.SetLLMRecordEnabled(*req.LLMRecord); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
}
|
||
changed := false
|
||
if req.TrafficCapture != nil {
|
||
if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
changed = true
|
||
}
|
||
if req.WebSearchEnabled != nil || req.WebSearchBackend != nil || req.BraveKey != nil || req.TavilyKey != nil || req.WebSearchProxy != nil {
|
||
// Fill unspecified fields from current state so a partial PUT doesn't reset them.
|
||
on, backend, _, _, _ := s.m.WebSearch()
|
||
if req.WebSearchEnabled != nil {
|
||
on = *req.WebSearchEnabled
|
||
}
|
||
if req.WebSearchBackend != nil {
|
||
backend = *req.WebSearchBackend
|
||
}
|
||
if err := s.m.SetWebSearch(on, backend, req.BraveKey, req.TavilyKey, req.WebSearchProxy); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
changed = true
|
||
}
|
||
if changed {
|
||
// rebuild agents so the new proxy/tools/prompt/web-search take hold (only if LLM configured).
|
||
s.cfgMu.Lock()
|
||
cfg, on := s.llmCfg, s.llmOn
|
||
s.cfgMu.Unlock()
|
||
if on {
|
||
if err := s.applyLLM(cfg); err != nil {
|
||
writeErr(w, 500, err.Error())
|
||
return
|
||
}
|
||
}
|
||
}
|
||
writeJSON(w, 200, s.settingsPayload())
|
||
}
|
||
|
||
// testWebSearch runs a real "test" search with the given (or currently saved)
|
||
// backend/proxy/key to verify the config can actually reach a search backend —
|
||
// mirroring testLLM. Backend/proxy come from the request (so the form's unsaved
|
||
// edits are tested); empty API keys fall back to stored values so the user need
|
||
// not retype them. Always 200 with {ok, error?, count?, backend?}.
|
||
func (s *Server) testWebSearch(w http.ResponseWriter, r *http.Request) {
|
||
var req struct {
|
||
Backend string `json:"web_search_backend"`
|
||
Proxy string `json:"web_search_proxy"`
|
||
BraveKey string `json:"brave_search_api_key"`
|
||
TavilyKey string `json:"tavily_search_api_key"`
|
||
}
|
||
// Empty body is fine — fall back entirely to the saved config below.
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
_, backend, storedBraveKey, storedTavilyKey, _ := s.m.WebSearch()
|
||
if strings.TrimSpace(req.Backend) != "" {
|
||
backend = req.Backend
|
||
}
|
||
// Proxy is taken from the form as-is (empty = direct), so testing reflects exactly
|
||
// what's shown — including an intentional "clear proxy to test direct" before saving.
|
||
proxy := strings.TrimSpace(req.Proxy)
|
||
// API keys are secrets the form omits when already saved, so fall back to stored.
|
||
braveKey := storedBraveKey
|
||
if strings.TrimSpace(req.BraveKey) != "" {
|
||
braveKey = req.BraveKey
|
||
}
|
||
tavilyKey := storedTavilyKey
|
||
if strings.TrimSpace(req.TavilyKey) != "" {
|
||
tavilyKey = req.TavilyKey
|
||
}
|
||
// Hard cap so a slow/blocked proxy can't hang the request.
|
||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||
defer cancel()
|
||
results, err := actool.WebSearchProbe(ctx, actool.WebSearchConfig{Backend: backend, BraveAPIKey: braveKey, TavilyAPIKey: tavilyKey, Proxy: proxy}, "test", 3)
|
||
if err != nil {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error(), "backend": backend})
|
||
return
|
||
}
|
||
if len(results) == 0 {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "error": "搜索返回 0 条结果(可能被限流或代理不通)", "backend": backend})
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"ok": true, "count": len(results), "backend": backend})
|
||
}
|
||
|
||
func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeErr(w, 404, "no active task")
|
||
return
|
||
}
|
||
var req struct {
|
||
Message string `json:"message"`
|
||
Attachments []chatAttachment `json:"attachments,omitempty"` // 方式1 上传的文件(路径相对任务工作目录)
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeErr(w, 400, err.Error())
|
||
return
|
||
}
|
||
// Persist + broadcast the human turn so the 主 Agent 编排会话 survives page
|
||
// reloads and updates live: the conversation lives in the activity stream as
|
||
// worker="mainagent" (the per-task activity table, replayed via SSE). With
|
||
// attachments, the activity's Detail carries {text, attachments} so the transcript
|
||
// renders attachment cards.
|
||
s.engine.emitActivity(t, userActivityWithAttachments("mainagent", req.Message, req.Attachments))
|
||
if ma := s.mainAgentRef(); ma != nil {
|
||
// Serialize per task: one main-agent run at a time so concurrent messages
|
||
// don't corrupt the shared exp<id>-main transcript. If a prior turn is still
|
||
// running, tell the caller to wait rather than starting a racing run.
|
||
s.chatMu.Lock()
|
||
if s.chatBusy[t.ID] {
|
||
s.chatMu.Unlock()
|
||
writeErr(w, 409, "主 Agent 正在处理上一条消息,请稍候")
|
||
return
|
||
}
|
||
// Cancellable context so stopChat can abort this turn without affecting
|
||
// the server's root context (mirrors the conversation stop pattern).
|
||
ctx, cancel := context.WithCancel(s.ctx)
|
||
s.chatBusy[t.ID] = true
|
||
s.chatCancel[t.ID] = cancel
|
||
s.chatMu.Unlock()
|
||
|
||
// Run the agent on a per-turn cancellable ctx (NOT r.Context()): the turn can
|
||
// take minutes (multi-tool loop), and binding it to the request lifecycle meant
|
||
// a page reload / proxy timeout cancelled it mid-run ("context canceled"). The
|
||
// steps + final answer stream back live via SSE (worker="mainagent"), so the
|
||
// handler returns immediately and the browser never needs to hold the request.
|
||
go func() {
|
||
defer func() {
|
||
cancel()
|
||
s.chatMu.Lock()
|
||
delete(s.chatBusy, t.ID)
|
||
delete(s.chatCancel, t.ID)
|
||
s.chatMu.Unlock()
|
||
}()
|
||
// emit every step (thinking/tool_use/tool_result/text/result) so the main
|
||
// agent session shows its work live, like worker/planner. The final answer
|
||
// is the captured "result" step — no separate reply emit (would duplicate).
|
||
emit := func(rec db.Activity) { s.engine.emitActivity(t, rec) }
|
||
maTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||
resume := func() { s.reviveTask(t) } // set_goals 新增目标 → 把任务拉回 running
|
||
// 把上传附件的【绝对路径】清单拼进发给 agent 的消息,它据此用 Read/Bash 打开文件。
|
||
// taskDir = agent 的工作目录(CWD),与 chatUpload 落盘、ensureRunDir 一致。
|
||
taskDir := filepath.Join(s.m.dir, "tasks", t.ID)
|
||
agentMsg := composeAgentMessage(req.Message, req.Attachments, taskDir)
|
||
if _, err := ma.Chat(ctx, maTaskID, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal); err != nil && ctx.Err() == nil {
|
||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: "(主 Agent 出错:" + err.Error() + ")"})
|
||
}
|
||
}()
|
||
writeJSON(w, 202, map[string]any{"status": "accepted", "mode": "llm"})
|
||
return
|
||
}
|
||
reply := s.fallbackChat(t, req.Message)
|
||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", Summary: reply})
|
||
writeJSON(w, 200, map[string]any{"reply": reply, "mode": "rule"})
|
||
}
|
||
|
||
// stopChat aborts the in-flight main-agent turn for a task (manual stop button).
|
||
// Mirrors pgStopConversation: cancels only the current turn; planner/workers are
|
||
// unaffected and continue running. The user can send a new message immediately.
|
||
func (s *Server) stopChat(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.PathValue("id"))
|
||
if t == nil {
|
||
writeErr(w, 404, "task not found")
|
||
return
|
||
}
|
||
s.chatMu.Lock()
|
||
cancel := s.chatCancel[t.ID]
|
||
s.chatMu.Unlock()
|
||
if cancel == nil {
|
||
writeJSON(w, 200, map[string]any{"status": "idle"})
|
||
return
|
||
}
|
||
cancel()
|
||
writeJSON(w, 200, map[string]any{"status": "stopping"})
|
||
}
|
||
|
||
// fallbackChat is the no-LLM human-steering handler: simple命令 + 态势摘要.
|
||
func (s *Server) fallbackChat(t *Task, msg string) string {
|
||
m := strings.TrimSpace(msg)
|
||
lower := strings.ToLower(m)
|
||
switch {
|
||
case strings.HasPrefix(m, "意图") || strings.HasPrefix(lower, "intent"):
|
||
text := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(m, "意图"), "intent"))
|
||
_, _ = t.Store.AddIntent(map[string]any{"summary": text}, 9, nil, "human")
|
||
return "已注入一条高优先级意图:" + text
|
||
case strings.HasPrefix(m, "提示") || strings.HasPrefix(lower, "hint"):
|
||
text := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(m, "提示"), "hint"))
|
||
_, _ = t.Store.AddNode(db.KindHint, map[string]any{"text": text}, 0, "active", "human", nil)
|
||
return "已记录提示,规划者下次会读到:" + text
|
||
default:
|
||
assetCounts, _ := s.m.Assets().CountsByType()
|
||
assets := 0
|
||
for _, c := range assetCounts {
|
||
assets += c
|
||
}
|
||
fnd, _ := t.Store.ListByKind(db.KindFinding, 1000)
|
||
fr, _ := t.Store.Frontier(1000)
|
||
return fmt.Sprintf("(规则模式,未配置 LLM)当前态势:资产 %d,待领意图 %d,确认发现 %d。\n可用指令:以\"意图 ...\"注入意图,\"提示 ...\"给规划者提示。", assets, len(fr), len(fnd))
|
||
}
|
||
}
|
||
|
||
func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeErr(w, 404, "no active task")
|
||
return
|
||
}
|
||
findings, _ := t.Store.ListByKind(db.KindFinding, 1000) // 纯漏洞(事实是独立的 KindFact,不进报告)
|
||
counts := map[string]int{}
|
||
for _, ty := range []string{"root_domain", "ip", "subdomain", "app", "service", "endpoint"} {
|
||
ns, _ := s.m.Assets().QueryByType(ty, 100000, 0)
|
||
if len(ns) > 0 {
|
||
counts[ty] = len(ns)
|
||
}
|
||
}
|
||
md := report.Markdown(report.Input{
|
||
Title: t.Description, Goal: t.Goal, GeneratedAt: time.Now(),
|
||
AssetCounts: counts, Findings: findings,
|
||
})
|
||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||
w.WriteHeader(200)
|
||
_, _ = w.Write([]byte(md))
|
||
}
|
||
|
||
func (s *Server) getAudit(w http.ResponseWriter, r *http.Request) {
|
||
t := s.m.ResolveTask(r.URL.Query().Get("task"))
|
||
if t == nil {
|
||
writeJSON(w, 200, []any{})
|
||
return
|
||
}
|
||
writeJSON(w, 200, map[string]any{"entries": t.Guard.Audit(), "attributions": t.Guard.Attributions()})
|
||
}
|
||
|
||
// gc is a no-op stub (GC not yet implemented in the new asset store).
|
||
func (s *Server) gc(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, 200, map[string]any{"removed": 0})
|
||
}
|
||
|
||
// --- utils ---
|
||
|
||
func cors(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
if r.Method == http.MethodOptions {
|
||
w.WriteHeader(204)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(code)
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||
writeJSON(w, code, map[string]any{"error": msg})
|
||
}
|
||
|
||
func atoiDefault(s string, d int) int {
|
||
if n, err := strconv.Atoi(s); err == nil {
|
||
return n
|
||
}
|
||
return d
|
||
}
|