feat: LLM 录制开关、模型列表第三方回退、命令页改为工具执行

在 review PR5 基础上的三处改进:

1. LLM 录制开关(默认关,settings.llm_record)
   - 录制页数据大小下拉框旁加开关;关闭时 Recorder.Stream 直接透传,
     不序列化、不累积、不写库,零开销。切换即时生效,无需重建 agent。

2. 模型列表拉取支持 Anthropic 兼容第三方
   - pgListModels 改为按序尝试多个候选端点:anthropic 的 /v1/models 拿不到时,
     剥掉 /anthropic 回退到 OpenAI 式 /models(自动切 Bearer 头)。
     修复 DeepSeek(anthropic) 加载模型 404。

3. 命令页改为「工具执行」列表
   - ListCommands 去掉 tool='Bash' 过滤,覆盖所有 tool_use;返回工具名列,
     搜索匹配工具名或参数。前端加「工具」列、标题/侧边栏改为「工具执行」。

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Autumn-27andClaude Opus 4.8 committed 2026-08-07 09:32:01 -04:00
1 parent 10f891a21d
commit ad9f14f140
12 files changed
+225 -83

No files matched your search

+1 -1
View File
@@ -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)
+25
View File
@@ -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).
+12 -3
View File
@@ -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 {
+85 -49
View File
@@ -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 白名单) ---