mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
10f891a21d
commit
ad9f14f140
12 files changed
+225
-83
No files matched your search
+10
-7
@@ -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)
|
||||
|
||||
+15
-8
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 白名单) ---
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">命令记录</h1>
|
||||
<h1 className="text-xl font-semibold tracking-tight">工具执行</h1>
|
||||
<Badge variant="secondary">{total}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,7 +125,7 @@ export default function CommandsPage() {
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索命令..."
|
||||
placeholder="搜索工具 / 参数..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 pl-8"
|
||||
@@ -187,21 +188,22 @@ export default function CommandsPage() {
|
||||
<TableHead className="w-[130px]">时间</TableHead>
|
||||
<TableHead className="w-[60px]">任务</TableHead>
|
||||
<TableHead className="w-[90px]">Worker</TableHead>
|
||||
<TableHead>命令</TableHead>
|
||||
<TableHead className="w-[110px]">工具</TableHead>
|
||||
<TableHead>输入</TableHead>
|
||||
<TableHead className="w-[60px]">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && commands.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-12 text-center">
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : commands.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-12 text-center text-sm text-muted-foreground">
|
||||
暂无命令记录
|
||||
<TableCell colSpan={6} className="py-12 text-center text-sm text-muted-foreground">
|
||||
暂无工具执行记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -225,9 +227,14 @@ export default function CommandsPage() {
|
||||
{cmd.worker || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{cmd.tool || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<code className="block truncate font-mono text-xs">
|
||||
{truncate(extractCommand(cmd.command), CMD_MAX_LEN)}
|
||||
{truncate(toolInput(cmd.command), CMD_MAX_LEN)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -256,6 +263,9 @@ export default function CommandsPage() {
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.worker || "-"}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs font-mono">
|
||||
{selected.tool || "-"}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fmtTime(selected.created_at)}
|
||||
</span>
|
||||
@@ -277,11 +287,11 @@ export default function CommandsPage() {
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
输入 Command
|
||||
输入 Input
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{extractCommand(selected.command)}
|
||||
{toolInput(selected.command)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -74,11 +75,39 @@ export default function LLMRecordsPage() {
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Recording on/off toggle (settings.llm_record; default off). When off the
|
||||
// backend records nothing.
|
||||
const [recEnabled, setRecEnabled] = React.useState(false);
|
||||
const [recBusy, setRecBusy] = React.useState(false);
|
||||
|
||||
// Inline detail panel (Burp-style split, not a dialog)
|
||||
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
|
||||
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
|
||||
// Load recording toggle state on mount.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.settings()
|
||||
.then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async (on: boolean) => {
|
||||
setRecBusy(true);
|
||||
setRecEnabled(on); // optimistic
|
||||
try {
|
||||
const s = await api.setSettings({ llm_record: on });
|
||||
setRecEnabled(!!s.llm_record);
|
||||
} catch {
|
||||
setRecEnabled(!on); // revert on failure
|
||||
} finally {
|
||||
setRecBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce session filter.
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setSessionQ(session.trim()), 300);
|
||||
@@ -168,6 +197,26 @@ export default function LLMRecordsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Recording on/off — off means no LLM calls are recorded */}
|
||||
<div className="flex items-center gap-2 rounded-md border px-2.5 py-1">
|
||||
<Switch
|
||||
id="llm-rec-toggle"
|
||||
size="sm"
|
||||
checked={recEnabled}
|
||||
onCheckedChange={toggleRecording}
|
||||
disabled={recBusy}
|
||||
/>
|
||||
<label
|
||||
htmlFor="llm-rec-toggle"
|
||||
className={cn(
|
||||
"cursor-pointer text-xs font-medium select-none",
|
||||
recEnabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{recEnabled ? "录制中" : "已关闭"}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{rangeStart}–{rangeEnd} / {total}
|
||||
|
||||
+1
-1
@@ -450,7 +450,7 @@ export const api = {
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
},
|
||||
|
||||
// ---- commands (Bash execution history) ----
|
||||
// ---- commands (tool execution history, any tool) ----
|
||||
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.task) sp.set("task", params.task);
|
||||
|
||||
@@ -464,6 +464,7 @@ export const tools: Tool[] = [
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
export const settings: Settings = {
|
||||
traffic_capture: true,
|
||||
llm_record: false,
|
||||
web_search_enabled: true,
|
||||
web_search_backend: "ddgs",
|
||||
brave_key_set: false,
|
||||
|
||||
@@ -338,6 +338,7 @@ export interface TrafficDetail {
|
||||
// ---- App settings (runtime toggles) ----
|
||||
export interface Settings {
|
||||
traffic_capture: boolean;
|
||||
llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用
|
||||
// Web search. brave_key_set / tavily_key_set reflect whether a key is stored
|
||||
// (the values are never returned). On PUT, send the corresponding field to set/clear.
|
||||
web_search_enabled: boolean;
|
||||
@@ -555,7 +556,8 @@ export interface CommandRecord {
|
||||
id: number;
|
||||
exploration_id: number;
|
||||
worker: string;
|
||||
command: string;
|
||||
tool: string;
|
||||
command: string; // raw tool input (JSON)
|
||||
output: string;
|
||||
is_error: boolean;
|
||||
created_at: string;
|
||||
|
||||
@@ -68,7 +68,7 @@ export const sidebarItems: NavGroup[] = [
|
||||
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
|
||||
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
|
||||
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
|
||||
{ id: "commands", title: "命令", url: "/function/commands", icon: Terminal },
|
||||
{ id: "commands", title: "工具执行", url: "/function/commands", icon: Terminal },
|
||||
{ id: "llm-records", title: "LLM 录制", url: "/function/llm-records", icon: Radio },
|
||||
{ id: "assets", title: "资产", url: "/function/assets", icon: Network },
|
||||
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
|
||||
|
||||
Reference in new issue
Block a user