diff --git a/db/commands.go b/db/commands.go index c9a81e9..74a92a3 100644 --- a/db/commands.go +++ b/db/commands.go @@ -6,19 +6,22 @@ import ( "time" ) -// CommandRecord is a paired Bash tool_use + tool_result from the activity table. +// CommandRecord is a paired tool_use + tool_result from the activity table +// (any tool, not just Bash). Command holds the raw tool input (JSON). type CommandRecord struct { ID int64 `json:"id"` ExpID int64 `json:"exploration_id"` Worker string `json:"worker"` + Tool string `json:"tool"` Command string `json:"command"` Output string `json:"output"` IsError bool `json:"is_error"` CreatedAt time.Time `json:"created_at"` } -// ListCommands returns Bash command executions (tool_use + paired tool_result) -// across all explorations, with optional filtering and pagination. +// ListCommands returns tool executions (tool_use + paired tool_result) across all +// explorations, with optional filtering and pagination. Covers every tool, not +// just Bash; q matches the tool name or its input. func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) { if size <= 0 { size = 50 @@ -28,7 +31,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco } offset := page * size - where := `WHERE u.tool = 'Bash' AND u.kind = 'tool_use'` + where := `WHERE u.kind = 'tool_use'` args := []any{} argN := 1 @@ -38,7 +41,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco argN++ } if q != "" { - where += fmt.Sprintf(` AND u.detail ILIKE $%d`, argN) + where += fmt.Sprintf(` AND (u.tool ILIKE $%d OR u.detail ILIKE $%d)`, argN, argN) args = append(args, "%"+q+"%") argN++ } @@ -52,7 +55,7 @@ func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandReco // data query: join tool_use with its tool_result dataQ := ` -SELECT u.id, u.exploration_id, COALESCE(u.worker,''), COALESCE(u.detail,''), +SELECT u.id, u.exploration_id, COALESCE(u.worker,''), COALESCE(u.tool,''), COALESCE(u.detail,''), COALESCE(r.detail,''), COALESCE(r.is_error, false), u.created_at FROM activity u LEFT JOIN activity r ON r.tool_use_id = u.tool_use_id AND r.kind = 'tool_result' @@ -70,7 +73,7 @@ LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1) out := []CommandRecord{} for rows.Next() { var c CommandRecord - if err := rows.Scan(&c.ID, &c.ExpID, &c.Worker, &c.Command, &c.Output, &c.IsError, &c.CreatedAt); err != nil { + if err := rows.Scan(&c.ID, &c.ExpID, &c.Worker, &c.Tool, &c.Command, &c.Output, &c.IsError, &c.CreatedAt); err != nil { return nil, 0, err } out = append(out, c) diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go index 2d6a9fa..37c256d 100644 --- a/llmrec/llmrec.go +++ b/llmrec/llmrec.go @@ -18,16 +18,18 @@ import ( // Recorder wraps an llm.Provider and records every completion call. type Recorder struct { - inner llm.Provider - pg *db.DB - model string // model name (from config, not in CompletionRequest) - prof string // LLM profile name (from llm_profiles) + inner llm.Provider + pg *db.DB + model string // model name (from config, not in CompletionRequest) + prof string // LLM profile name (from llm_profiles) + enabled func() bool // reports whether recording is currently on; nil = always record } -// Wrap returns a Provider that records all calls to pg. -// profName is the LLM profile name (e.g. "default"); may be empty. -func Wrap(inner llm.Provider, pg *db.DB, model, profName string) *Recorder { - return &Recorder{inner: inner, pg: pg, model: model, prof: profName} +// Wrap returns a Provider that records calls to pg when enabled() reports true. +// profName is the LLM profile name (e.g. "default"); may be empty. A nil enabled +// predicate records unconditionally (backward-compatible default). +func Wrap(inner llm.Provider, pg *db.DB, model, profName string, enabled func() bool) *Recorder { + return &Recorder{inner: inner, pg: pg, model: model, prof: profName, enabled: enabled} } // parseSession extracts task id and worker role from session strings like @@ -61,6 +63,11 @@ func parseSession(s string) (taskID, worker string) { // harness via transcript.WithSessionID; reading it per-call is race-free even when // planner and multiple workers share one Recorder instance. func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { + // Recording off → passthrough with zero overhead: no request serialization, + // no response accumulation, no DB insert. + if r.enabled != nil && !r.enabled() { + return r.inner.Stream(ctx, req) + } start := time.Now() session := transcript.SessionIDFrom(ctx) taskID, worker := parseSession(session) diff --git a/server/commands.go b/server/commands.go index cd8e8c2..3f63397 100644 --- a/server/commands.go +++ b/server/commands.go @@ -5,7 +5,7 @@ import ( "strconv" ) -// pgListCommands returns Bash command executions filtered from the activity table. +// pgListCommands returns tool executions (any tool) from the activity table. func (s *Server) pgListCommands(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := atoiDefault(q.Get("page"), 0) diff --git a/server/manager.go b/server/manager.go index 346d31e..52ea0a8 100644 --- a/server/manager.go +++ b/server/manager.go @@ -64,6 +64,7 @@ type Manager struct { tasks map[string]*Task active string trafficOn bool // 流量捕获开关(默认关;settings.traffic_capture) + llmRecOn bool // LLM 录制开关(默认关;settings.llm_record) // 联网搜索开关与来源(默认关;settings.web_search_*)。brave-free 需要 braveKey;tavily 需要 tavilyKey。 // webSearchProxy 是独立出口代理(http/https/socks5),与记录流量的 MITM 代理无关。 webSearchOn bool @@ -82,6 +83,7 @@ const ( settingTavilyKey = "tavily_search_api_key" settingWebSearchProxy = "web_search_proxy" settingWorkers = "workers" + settingLLMRecord = "llm_record" // defaultWebSearchBackend is used when web search is on but no backend was picked. defaultWebSearchBackend = "ddgs" // defaultWorkers is the concurrent work-agent count when the setting is unset. @@ -158,6 +160,8 @@ func NewManager(dir, proxyAddr string) (*Manager, error) { // Asset auto-completion engine (§5): HTTP probes routed through the recording // proxy (via m.ProxyAddr, which honors the traffic-capture toggle). m.trafficOn = pg.GetBool(settingTrafficCapture, false) + // LLM 录制开关(默认关)。录制器每次调用时读取此标志。 + m.llmRecOn = pg.GetBool(settingLLMRecord, false) // Load persisted web-search config (default: off, ddgs). m.webSearchOn = pg.GetBool(settingWebSearchOn, false) if v, ok, _ := pg.GetSetting(settingWebSearchBackend); ok && v != "" { @@ -206,6 +210,27 @@ func (m *Manager) SetTrafficEnabled(on bool) error { return nil } +// LLMRecordEnabled reports whether LLM request/response recording is on +// (默认关;settings.llm_record). The recorder consults this per call, so the +// toggle takes effect immediately without rebuilding agents. +func (m *Manager) LLMRecordEnabled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.llmRecOn +} + +// SetLLMRecordEnabled persists and applies the LLM-record toggle. Effective at +// once — no applyLLM needed, since the recorder reads the flag on every call. +func (m *Manager) SetLLMRecordEnabled(on bool) error { + if err := m.pg.SetBool(settingLLMRecord, on); err != nil { + return err + } + m.mu.Lock() + m.llmRecOn = on + m.mu.Unlock() + return nil +} + // WebSearch returns the current web-search config: whether it is enabled, the // backend ("ddgs" | "brave-free" | "tavily"), the Brave API key, the Tavily API // key (each empty unless set), and the dedicated egress proxy (empty = direct). diff --git a/server/server.go b/server/server.go index a8a40e7..52f5389 100644 --- a/server/server.go +++ b/server/server.go @@ -336,7 +336,7 @@ func (s *Server) applyLLM(cfg agent.Config) error { s.cfgMu.Lock() profName := s.llmProf s.cfgMu.Unlock() - prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, profName, s.m.LLMRecordEnabled) pl, wk := s.buildPlannerWorker(prov, cfg) s.engine.UseLLM(pl, wk) @@ -400,7 +400,7 @@ func (s *Server) agentsForProfile(id int64) (*agent.Planner, *agent.Worker) { } // 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) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled) } pl, wk := s.buildPlannerWorker(prov, cfg) s.profAgents[id] = &profBundle{pl: pl, wk: wk} @@ -427,7 +427,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent { } // 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) + prov = llmrec.Wrap(prov, s.m.PG(), cfg.Model, p.Name, s.m.LLMRecordEnabled) } tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts")) win := cfg.CompactionWindow() @@ -1458,6 +1458,7 @@ func (s *Server) settingsPayload() map[string]any { 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) != "", @@ -1488,6 +1489,7 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) { 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"` @@ -1514,6 +1516,13 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) { 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 { diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 4036892..470f134 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -1539,7 +1539,9 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) { } // pgListModels fetches available models from the provider's API endpoint. -// Supports both OpenAI-format (GET /models) and Anthropic-format (GET /v1/models). +// Supports OpenAI-format (GET /models) and Anthropic-format (GET /v1/models); for +// Anthropic-compatible third parties (e.g. DeepSeek) whose model list lives only on +// the OpenAI path, it falls back to the OpenAI endpoint at the stripped root. func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { var req struct { Provider string `json:"provider"` // "openai" | "anthropic" @@ -1567,29 +1569,48 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { 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 + // Candidate endpoints to try in order. Some Anthropic-compatible providers + // (e.g. DeepSeek) implement /v1/messages under an /anthropic path but expose + // the model list only on their OpenAI-format path — so for anthropic we fall + // back to the OpenAI endpoint at the stripped root. + type candidate struct { + url string + hdr http.Header + } + bearerHdr := func() http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+apiKey) + return h + } + anthropicHdr := func() http.Header { + h := http.Header{} + h.Set("x-api-key", apiKey) + h.Set("anthropic-version", "2023-06-01") + return h + } + + var candidates []candidate switch provider { case "openai": 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) + b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/") + candidates = append(candidates, candidate{b + "/models", bearerHdr()}) 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") + b := strings.TrimRight(strings.TrimSuffix(baseURL, "/v1/messages"), "/") + candidates = append(candidates, candidate{b + "/v1/models", anthropicHdr()}) + // Fallback for Anthropic-compatible third parties whose model list lives on + // the OpenAI path: strip a trailing /anthropic and try the OpenAI endpoint. + if root := strings.TrimRight(strings.TrimSuffix(b, "/anthropic"), "/"); root != b { + candidates = append(candidates, + candidate{root + "/models", bearerHdr()}, + candidate{root + "/v1/models", bearerHdr()}, + ) + } } // Build HTTP client with optional proxy. @@ -1601,42 +1622,57 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) { } 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) + // Try each candidate; return the first that yields a non-empty model list. + var lastErr string + emptyOK := false + for _, c := range candidates { + httpReq, err := http.NewRequestWithContext(r.Context(), http.MethodGet, c.url, nil) + if err != nil { + lastErr = "构建请求失败: " + err.Error() + continue } + httpReq.Header = c.hdr + resp, err := client.Do(httpReq) + if err != nil { + lastErr = "请求失败: " + err.Error() + continue + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Sprintf("API 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 512)])) + continue + } + // Both OpenAI and Anthropic return {"data": [{"id": "..."},...]}. + var parsed struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + lastErr = "解析响应失败: " + err.Error() + continue + } + models := make([]string, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + models = append(models, m.ID) + } + } + if len(models) > 0 { + writeJSON(w, 200, map[string]any{"ok": true, "models": models}) + return + } + emptyOK = true // 200 but no model ids — keep trying other candidates } - writeJSON(w, 200, map[string]any{"ok": true, "models": models}) + if emptyOK { + writeJSON(w, 200, map[string]any{"ok": true, "models": []string{}}) + return + } + if lastErr == "" { + lastErr = "未获取到模型列表" + } + writeJSON(w, 200, map[string]any{"ok": false, "error": lastErr}) } // --- prompt template helpers (Go text/template + catalog 白名单) --- diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx index d49959c..918805a 100644 --- a/web/src/app/(main)/function/commands/page.tsx +++ b/web/src/app/(main)/function/commands/page.tsx @@ -43,12 +43,13 @@ function fmtTime(ts: string) { }); } -// extractCommand parses the tool_use detail (JSON like {"command":"..."}) and -// returns just the command string. Falls back to raw text if not JSON. -function extractCommand(raw: string): string { +// toolInput renders a tool's raw input for display. Bash's {"command":"..."} is +// unwrapped to the bare command; other tools show their pretty-printed JSON args. +function toolInput(raw: string): string { try { const obj = JSON.parse(raw); if (obj && typeof obj.command === "string") return obj.command; + return JSON.stringify(obj, null, 2); } catch { /* not JSON */ } return raw; } @@ -114,7 +115,7 @@ export default function CommandsPage() {
- {truncate(extractCommand(cmd.command), CMD_MAX_LEN)}
+ {truncate(toolInput(cmd.command), CMD_MAX_LEN)}
- {extractCommand(selected.command)}
+ {toolInput(selected.command)}