feat: 添加LLM模型清单、命令执行记录录制和LLM请求记录录制功能
This commit is contained in:
1 parent
0f94849012
commit
5b58330f01
14 files changed
+1546
-197
No files matched your search
@@ -0,0 +1,59 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// pgListCommands returns Bash command executions filtered from the activity table.
|
||||
func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := atoiDefault(q.Get("page"), 0)
|
||||
size := atoiDefault(q.Get("size"), 50)
|
||||
keyword := q.Get("q")
|
||||
|
||||
var expID *int64
|
||||
if tv := q.Get("task"); tv != "" {
|
||||
if n, err := strconv.ParseInt(tv, 10, 64); err == nil {
|
||||
expID = &n
|
||||
}
|
||||
}
|
||||
|
||||
records, total, err := s.m.PG().ListCommands(expID, keyword, page, size)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"commands": records, "total": total})
|
||||
}
|
||||
|
||||
// pgListLLMRecords returns paginated LLM call records.
|
||||
func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page := atoiDefault(q.Get("page"), 0)
|
||||
size := atoiDefault(q.Get("size"), 50)
|
||||
model := q.Get("model")
|
||||
session := q.Get("session")
|
||||
|
||||
records, total, err := s.m.PG().ListLLMRecords(model, session, page, size)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"records": records, "total": total})
|
||||
}
|
||||
|
||||
// pgGetLLMRecord returns a single LLM record with full request/response bodies.
|
||||
func (s *Server) pgGetLLMRecord(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "invalid id")
|
||||
return
|
||||
}
|
||||
rec, err := s.m.PG().GetLLMRecord(id)
|
||||
if err != nil {
|
||||
writeErr(w, 404, "not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, rec)
|
||||
}
|
||||
@@ -137,6 +137,9 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
log.Printf("[llmrec] create table: %v", err)
|
||||
}
|
||||
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
|
||||
if proxyAddr != "" {
|
||||
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/artex/report"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ type Server struct {
|
||||
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
|
||||
@@ -245,6 +247,9 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
if cfg.APIKey == "" {
|
||||
return cfg, false
|
||||
}
|
||||
s.cfgMu.Lock()
|
||||
s.llmProf = p.Name
|
||||
s.cfgMu.Unlock()
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
@@ -327,6 +332,11 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
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)
|
||||
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||
s.engine.UseLLM(pl, wk)
|
||||
|
||||
@@ -388,6 +398,10 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) {
|
||||
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil, nil
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||
s.profAgents[id] = &profBundle{pl: pl, wk: wk}
|
||||
log.Printf("[engine] built dedicated planner/worker for LLM profile %d (%s / %s)", id, cfg.Provider(), cfg.Model)
|
||||
@@ -411,6 +425,10 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
|
||||
log.Printf("[chat] build provider for LLM profile %d failed: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||||
win := cfg.CompactionWindow()
|
||||
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win)
|
||||
@@ -496,6 +514,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/gc", s.gc)
|
||||
mux.HandleFunc("GET /api/traffic", s.getTraffic)
|
||||
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("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)
|
||||
@@ -582,6 +603,7 @@ func (s *Server) Handler() http.Handler {
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -1537,6 +1538,107 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// pgListModels fetches available models from the provider's API endpoint.
|
||||
// Supports both OpenAI-format (GET /models) and Anthropic-format (GET /v1/models).
|
||||
func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"` // "openai" | "anthropic"
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Proxy string `json:"proxy"`
|
||||
ProfileID *int64 `json:"profile_id"` // fallback: use stored key from this profile
|
||||
}
|
||||
if err := decode(r, &req); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
// Resolve API key: form input > profile stored key.
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
if apiKey == "" && req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
apiKey = p.APIKey
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
|
||||
// Build the models endpoint URL and request headers per provider format.
|
||||
var modelsURL string
|
||||
var hdr http.Header
|
||||
switch provider {
|
||||
case "openai":
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
baseURL = strings.TrimSuffix(baseURL, "/chat/completions")
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
modelsURL = baseURL + "/models"
|
||||
hdr = http.Header{}
|
||||
hdr.Set("Authorization", "Bearer "+apiKey)
|
||||
default: // anthropic
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.anthropic.com"
|
||||
}
|
||||
baseURL = strings.TrimSuffix(baseURL, "/v1/messages")
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
modelsURL = baseURL + "/v1/models"
|
||||
hdr = http.Header{}
|
||||
hdr.Set("x-api-key", apiKey)
|
||||
hdr.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// Build HTTP client with optional proxy.
|
||||
transport := &http.Transport{}
|
||||
if p := strings.TrimSpace(req.Proxy); p != "" {
|
||||
if pu, err := url.Parse(p); err == nil {
|
||||
transport.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: 30 * time.Second}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, modelsURL, nil)
|
||||
if err != nil {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "构建请求失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
httpReq.Header = hdr
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "请求失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": fmt.Sprintf("API 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 512)]))})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse response: both OpenAI and Anthropic return {"data": [{"id": "..."},...]}.
|
||||
var parsed struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "解析响应失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
models := make([]string, 0, len(parsed.Data))
|
||||
for _, m := range parsed.Data {
|
||||
if m.ID != "" {
|
||||
models = append(models, m.ID)
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "models": models})
|
||||
}
|
||||
|
||||
// --- prompt template helpers (Go text/template + catalog 白名单) ---
|
||||
|
||||
// validateTemplate parses the template and rejects any {{.Var}} not in the catalog.
|
||||
|
||||
Reference in new issue
Block a user