diff --git a/.gitignore b/.gitignore
index 64bcef1..6e12987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ Thumbs.db
# 内嵌前端的单二进制产物
/artex
/server/webui/dist/
+/dist/
# Docker 部署生成的本地环境(含密码/密钥,不提交)
.env
\ No newline at end of file
diff --git a/db/commands.go b/db/commands.go
index 74a92a3..82b9c45 100644
--- a/db/commands.go
+++ b/db/commands.go
@@ -152,7 +152,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
}
// ListLLMRecords returns paginated LLM records with optional filters.
-func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord, int, error) {
+func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMRecord, int, error) {
if size <= 0 {
size = 50
}
@@ -174,6 +174,11 @@ func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord,
args = append(args, "%"+session+"%")
argN++
}
+ if task != "" {
+ where += fmt.Sprintf(` AND COALESCE(task_id,'') = $%d`, argN)
+ args = append(args, task)
+ argN++
+ }
var total int
if err := d.QueryRow(`SELECT COUNT(*) FROM llm_records `+where, args...).Scan(&total); err != nil {
@@ -204,6 +209,42 @@ func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord,
return out, total, rows.Err()
}
+// LLMTask is one distinct task with its LLM-record count.
+type LLMTask struct {
+ TaskID string `json:"task_id"`
+ Count int `json:"count"`
+}
+
+// LLMTasks returns distinct non-empty task_ids with record counts, most recent
+// first — powers the LLM-records page's task picker.
+func (d *DB) LLMTasks() ([]LLMTask, error) {
+ rows, err := d.Query(`SELECT task_id, COUNT(*) AS n FROM llm_records
+WHERE COALESCE(task_id,'') <> '' GROUP BY task_id ORDER BY MAX(id) DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []LLMTask
+ for rows.Next() {
+ var t LLMTask
+ if err := rows.Scan(&t.TaskID, &t.Count); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// DeleteLLMRecords removes every LLM record for one exact task_id — the same
+// match the page's task picker/filter uses. Returns rows deleted.
+func (d *DB) DeleteLLMRecords(task string) (int64, error) {
+ res, err := d.Exec(`DELETE FROM llm_records WHERE COALESCE(task_id,'') = $1`, task)
+ if err != nil {
+ return 0, err
+ }
+ return res.RowsAffected()
+}
+
// GetLLMRecord returns a single LLM record with full request/response bodies.
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
var r LLMRecord
diff --git a/server/commands.go b/server/commands.go
index 3f63397..bcb35e0 100644
--- a/server/commands.go
+++ b/server/commands.go
@@ -3,6 +3,7 @@ package server
import (
"net/http"
"strconv"
+ "strings"
)
// pgListCommands returns tool executions (any tool) from the activity table.
@@ -34,8 +35,9 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
size := atoiDefault(q.Get("size"), 50)
model := q.Get("model")
session := q.Get("session")
+ task := q.Get("task")
- records, total, err := s.m.PG().ListLLMRecords(model, session, page, size)
+ records, total, err := s.m.PG().ListLLMRecords(model, session, task, page, size)
if err != nil {
writeErr(w, 500, err.Error())
return
@@ -43,6 +45,33 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"records": records, "total": total})
}
+// pgLLMTasks returns distinct recorded tasks with counts, for the page's task
+// picker (pick a task → filter the list, then delete its conversations).
+func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) {
+ tasks, err := s.m.PG().LLMTasks()
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"tasks": tasks})
+}
+
+// pgDeleteLLMRecords removes all LLM records for one exact task_id (the same
+// match the task picker uses). Empty task → 400. Returns rows deleted.
+func (s *Server) pgDeleteLLMRecords(w http.ResponseWriter, r *http.Request) {
+ task := strings.TrimSpace(r.URL.Query().Get("task"))
+ if task == "" {
+ writeErr(w, 400, "missing task")
+ return
+ }
+ n, err := s.m.PG().DeleteLLMRecords(task)
+ if err != nil {
+ writeErr(w, 500, err.Error())
+ return
+ }
+ writeJSON(w, 200, map[string]any{"deleted": n})
+}
+
// 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)
diff --git a/server/server.go b/server/server.go
index 2295a4c..0729c8d 100644
--- a/server/server.go
+++ b/server/server.go
@@ -531,9 +531,14 @@ func (s *Server) Handler() http.Handler {
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)
@@ -1678,6 +1683,81 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) {
})
}
+// 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) {
diff --git a/traffic/traffic.go b/traffic/traffic.go
index 6f57323..ff1d5a3 100644
--- a/traffic/traffic.go
+++ b/traffic/traffic.go
@@ -11,12 +11,14 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
+ "io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
+ "regexp"
"strings"
"sync"
"sync/atomic"
@@ -55,7 +57,7 @@ type Traffic struct {
dir string
addr string
db *sql.DB
- wmu sync.Mutex
+ wmu sync.Mutex // serializes record() vs DeleteHost (incl. blob GC)
seq atomic.Int64
proxy *mproxy.Proxy
// pass is the set of hosts whose MITM interception failed for a proxy/protocol
@@ -186,6 +188,10 @@ func proxyCausedErr(err error) bool {
}
func (t *Traffic) record(f *mproxy.Flow) {
+ // The write lock covers blob + tree + index writes, so DeleteHost (and its
+ // blob GC) can run under the same lock without racing a concurrent record.
+ t.wmu.Lock()
+ defer t.wmu.Unlock()
host := f.Request.URL.Hostname()
method := f.Request.Method
tmpl := db.TemplatePath(f.Request.URL.EscapedPath())
@@ -221,12 +227,10 @@ func (t *Traffic) record(f *mproxy.Flow) {
_ = os.WriteFile(filepath.Join(exDir, "meta.json"), []byte(meta), 0o644)
rel, _ := filepath.Rel(t.dir, exDir)
- t.wmu.Lock()
_, _ = t.db.Exec(`INSERT OR REPLACE INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
id, now.Unix(), host, method, tmpl, f.Request.URL.String(), f.Response.StatusCode, ct,
len(f.Request.Body), len(f.Response.Body), rel)
- t.wmu.Unlock()
}
// bodyOrBlob returns the body inline if small, else stores it content-addressed
@@ -384,6 +388,32 @@ func (t *Traffic) Get(id string) (req, resp string, err error) {
return string(rb), string(pb), nil
}
+// HostCount is one distinct recorded host plus its exchange count.
+type HostCount struct {
+ Host string `json:"host"`
+ Count int `json:"count"`
+}
+
+// Hosts returns distinct recorded hosts with exchange counts, most recent
+// activity first — powers the page's target picker.
+func (t *Traffic) Hosts() ([]HostCount, error) {
+ rows, err := t.db.Query(`SELECT host, COUNT(*) AS n, MAX(ts) AS last FROM exchanges GROUP BY host ORDER BY last DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []HostCount
+ for rows.Next() {
+ var h HostCount
+ var last int64
+ if err := rows.Scan(&h.Host, &h.Count, &last); err != nil {
+ return nil, err
+ }
+ out = append(out, h)
+ }
+ return out, rows.Err()
+}
+
// Count returns total recorded exchanges.
func (t *Traffic) Count() (int, error) {
var n int
@@ -391,6 +421,124 @@ func (t *Traffic) Count() (int, error) {
return n, err
}
+// DeleteHost removes every recorded exchange whose host contains the given
+// substring — mirroring the UI's host filter, so what you filtered is what gets
+// deleted: the index rows plus each matched host's file tree (
//…).
+// Content-addressed blobs in _blobs are shared across hosts, so instead of
+// deleting by host they are garbage-collected afterwards: any blob no longer
+// referenced by a remaining exchange tree is removed (frees the data volume).
+// Returns rows deleted.
+func (t *Traffic) DeleteHost(host string) (int64, error) {
+ like := "%" + host + "%"
+ t.wmu.Lock()
+ defer t.wmu.Unlock()
+ // Collect distinct matched hosts first (index is the source of truth for
+ // tree names) so the trees are removed even if the DELETE row count is 0.
+ rows, err := t.db.Query(`SELECT DISTINCT host FROM exchanges WHERE host LIKE ?`, like)
+ if err != nil {
+ return 0, err
+ }
+ var hosts []string
+ for rows.Next() {
+ var h string
+ if err := rows.Scan(&h); err != nil {
+ rows.Close()
+ return 0, err
+ }
+ hosts = append(hosts, h)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ res, err := t.db.Exec(`DELETE FROM exchanges WHERE host LIKE ?`, like)
+ if err != nil {
+ return 0, err
+ }
+ n, _ := res.RowsAffected()
+ for _, h := range hosts {
+ os.RemoveAll(filepath.Join(t.dir, sanitize(h)))
+ }
+ if n > 0 {
+ _ = t.gcBlobs()
+ }
+ return n, nil
+}
+
+// DeleteHostsExact removes recorded exchanges for a set of EXACT hosts (the
+// batch path for the page's multi-select delete): index rows + each host's file
+// tree, then one blob-GC pass. Exact match — unlike DeleteHost's substring —
+// so picking "api.example.com" never sweeps "api.example.com.cn". Returns rows
+// deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass).
+func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) {
+ t.wmu.Lock()
+ defer t.wmu.Unlock()
+ var deleted int64
+ removed := make(map[string]bool)
+ for _, h := range hosts {
+ res, err := t.db.Exec(`DELETE FROM exchanges WHERE host=?`, h)
+ if err != nil {
+ return deleted, err
+ }
+ n, _ := res.RowsAffected()
+ deleted += n
+ if !removed[h] {
+ os.RemoveAll(filepath.Join(t.dir, sanitize(h)))
+ removed[h] = true
+ }
+ }
+ if deleted > 0 {
+ _ = t.gcBlobs()
+ }
+ return deleted, nil
+}
+
+// gcBlobs removes content-addressed blobs in _blobs that no remaining exchange
+// tree references. References appear as "@blob sha256:" lines inside the
+// tree's request.http/response.http files (see bodyOrBlob). Best-effort: walk
+// errors only skip cleanup of the affected files. Callers must hold wmu so this
+// never races a concurrent record() writing a fresh blob + tree.
+func (t *Traffic) gcBlobs() error {
+ refs := make(map[string]struct{})
+ blobRe := regexp.MustCompile(`@blob sha256:([0-9a-f]{64})`)
+ err := filepath.WalkDir(t.dir, func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d == nil {
+ return nil // skip unreadable entries
+ }
+ if d.IsDir() {
+ // _blobs/_index/_ca never contain references; skip their subtrees.
+ if p != t.dir && strings.HasPrefix(d.Name(), "_") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if n := d.Name(); n != "request.http" && n != "response.http" {
+ return nil
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return nil
+ }
+ for _, m := range blobRe.FindAllSubmatch(b, -1) {
+ refs[string(m[1])] = struct{}{}
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return filepath.WalkDir(filepath.Join(t.dir, "_blobs", "sha256"), func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d == nil || d.IsDir() {
+ return nil
+ }
+ h := strings.TrimSuffix(d.Name(), ".bin")
+ if _, ok := refs[h]; !ok {
+ os.Remove(p)
+ }
+ return nil
+ })
+}
+
// query returns one page of exchange metadata filtered by host and/or a url
// substring. Default page size is intentionally small (3) to keep tool results
// lightweight and capped at 10; page is 0-based (page*limit offset).
diff --git a/traffic/traffic_test.go b/traffic/traffic_test.go
new file mode 100644
index 0000000..25d95b9
--- /dev/null
+++ b/traffic/traffic_test.go
@@ -0,0 +1,246 @@
+package traffic
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestDeleteHost verifies the delete contract: rows for hosts containing the
+// substring are removed together with their file trees, non-matching hosts are
+// untouched, and the count is right.
+func TestDeleteHost(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ // Seed two hosts' index rows + trees directly (record() needs a live Flow).
+ for i, h := range []string{"a.example.com", "b.example.com"} {
+ id := fmt.Sprintf("1-%04d", i+1)
+ exDir := filepath.Join(dir, h, "GET", id)
+ if err := os.MkdirAll(exDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(exDir, "meta.json"), []byte(fmt.Sprintf(`{"id":%q,"host":%q}`, id, h)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ id, i+1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // Substring: "a.example" matches a.example.com only, leaves b.example.com.
+ n, err := tr.DeleteHost("a.example")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Fatalf("deleted=%d, want 1", n)
+ }
+ // Tree removed for the target, intact for the other host.
+ if _, err := os.Stat(filepath.Join(dir, "a.example.com")); !os.IsNotExist(err) {
+ t.Fatalf("a.example.com tree still exists (stat err=%v)", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "b.example.com")); err != nil {
+ t.Fatalf("b.example.com tree removed: %v", err)
+ }
+ // Index reduced to the other host's single row.
+ var c int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err != nil {
+ t.Fatal(err)
+ }
+ if c != 1 {
+ t.Fatalf("rows=%d, want 1", c)
+ }
+ // A substring matching nothing is a no-op, not an error.
+ n, err = tr.DeleteHost("nope.example")
+ if err != nil || n != 0 {
+ t.Fatalf("DeleteHost(missing)=%d, err=%v; want 0, nil", n, err)
+ }
+ // A broader substring sweeps the remaining host too.
+ if n, err = tr.DeleteHost("example.com"); err != nil || n != 1 {
+ t.Fatalf("DeleteHost(example.com)=%d, err=%v; want 1, nil", n, err)
+ }
+ c = 0
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err == nil && c != 0 {
+ t.Fatalf("rows=%d, want 0 after full sweep", c)
+ }
+}
+
+// TestHosts verifies the target picker contract: distinct hosts with counts,
+// most recent activity first.
+func TestHosts(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+ for i, row := range []struct {
+ host string
+ ts int64
+ }{{"old.example.com", 1}, {"new.example.com", 3}, {"old.example.com", 2}} {
+ id := fmt.Sprintf("1-%04d", i+1)
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ id, row.ts, row.host, "GET", "/", "http://"+row.host+"/", 200, "text/html", 0, 0, row.host+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ hosts, err := tr.Hosts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hosts) != 2 {
+ t.Fatalf("hosts=%d, want 2", len(hosts))
+ }
+ // newest activity (ts=3) first
+ if hosts[0].Host != "new.example.com" || hosts[0].Count != 1 {
+ t.Fatalf("hosts[0]=%+v, want new.example.com/1", hosts[0])
+ }
+ if hosts[1].Host != "old.example.com" || hosts[1].Count != 2 {
+ t.Fatalf("hosts[1]=%+v, want old.example.com/2", hosts[1])
+ }
+}
+
+// TestDeleteHostsExact verifies the batch delete: exact host match only — a
+// host whose name contains another as a substring is untouched — duplicates in
+// the batch are harmless, and the per-host trees are removed.
+func TestDeleteHostsExact(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+ seed := func(id, h string) {
+ exDir := filepath.Join(dir, h, "GET", id)
+ if err := os.MkdirAll(exDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ id, 1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // "api.example.com" is a substring of "api.example.com.cn".
+ seed("1-0001", "api.example.com")
+ seed("1-0002", "api.example.com.cn")
+ seed("1-0003", "shop.example.com")
+
+ // Duplicate entry in the batch must not double-delete or error.
+ n, err := tr.DeleteHostsExact([]string{"api.example.com", "api.example.com", "shop.example.com"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 2 {
+ t.Fatalf("deleted=%d, want 2", n)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "api.example.com")); !os.IsNotExist(err) {
+ t.Fatalf("api.example.com tree still exists: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "shop.example.com")); !os.IsNotExist(err) {
+ t.Fatalf("shop.example.com tree still exists: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "api.example.com.cn")); err != nil {
+ t.Fatalf("api.example.com.cn removed by an exact delete that shouldn't match: %v", err)
+ }
+ var c int
+ if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err != nil {
+ t.Fatal(err)
+ }
+ if c != 1 {
+ t.Fatalf("rows=%d, want 1 (api.example.com.cn only)", c)
+ }
+}
+
+// TestDeleteHostGCBlobs verifies blob garbage collection: after a host's trees
+// are removed, blobs referenced by no remaining exchange are deleted, while
+// blobs still referenced (including shared ones) survive.
+func TestDeleteHostGCBlobs(t *testing.T) {
+ dir := t.TempDir()
+ tr, err := Open(dir, "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tr.Close()
+
+ // Two distinct blobs + one shared blob (referenced by two hosts).
+ blobA := filepath.Join(dir, "_blobs", "sha256", "aa", "aa", strings.Repeat("a", 64)+".bin")
+ blobB := filepath.Join(dir, "_blobs", "sha256", "bb", "bb", strings.Repeat("b", 64)+".bin")
+ blobC := filepath.Join(dir, "_blobs", "sha256", "cc", "cc", strings.Repeat("c", 64)+".bin")
+ for _, b := range []string{blobA, blobB, blobC} {
+ if err := os.MkdirAll(filepath.Dir(b), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(b, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ seed := func(id, h, ref string) {
+ exDir := filepath.Join(dir, h, "GET", id)
+ if err := os.MkdirAll(exDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ body := "no blob"
+ if ref != "" {
+ body = "@blob sha256:" + ref + " (len=1)"
+ }
+ if err := os.WriteFile(filepath.Join(exDir, "request.http"), []byte("GET / HTTP/1.1\n\n"+body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(exDir, "response.http"), []byte("HTTP 200 OK\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
+VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
+ id, 1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil {
+ t.Fatal(err)
+ }
+ }
+ ha := strings.Repeat("a", 64)
+ hb := strings.Repeat("b", 64)
+ hc := strings.Repeat("c", 64)
+ seed("1-0001", "a.example.com", ha) // sole reference to blobA
+ seed("1-0002", "b.example.com", hb) // sole reference to blobB
+ seed("1-0003", "c.example.com", hc) // shares blobC with d
+ seed("1-0004", "d.example.com", hc)
+
+ // Delete a: blobA orphaned → removed; blobB/blobC still referenced → kept.
+ if n, err := tr.DeleteHost("a.example"); err != nil || n != 1 {
+ t.Fatalf("DeleteHost(a.example)=%d, err=%v; want 1, nil", n, err)
+ }
+ if _, err := os.Stat(blobA); !os.IsNotExist(err) {
+ t.Fatalf("orphaned blobA still exists: %v", err)
+ }
+ if _, err := os.Stat(blobB); err != nil {
+ t.Fatalf("referenced blobB removed: %v", err)
+ }
+ if _, err := os.Stat(blobC); err != nil {
+ t.Fatalf("shared blobC removed while d still references it: %v", err)
+ }
+
+ // Delete c (shares blobC with d): blobC must survive.
+ if n, err := tr.DeleteHost("c.example"); err != nil || n != 1 {
+ t.Fatalf("DeleteHost(c.example)=%d, err=%v; want 1, nil", n, err)
+ }
+ if _, err := os.Stat(blobC); err != nil {
+ t.Fatalf("shared blobC removed after deleting one sharer: %v", err)
+ }
+
+ // Delete d: last reference gone → blobC collected.
+ if n, err := tr.DeleteHost("d.example"); err != nil || n != 1 {
+ t.Fatalf("DeleteHost(d.example)=%d, err=%v; want 1, nil", n, err)
+ }
+ if _, err := os.Stat(blobC); !os.IsNotExist(err) {
+ t.Fatalf("blobC still exists after last reference removed: %v", err)
+ }
+}
diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx
index 2be4866..cb70c44 100644
--- a/web/src/app/(main)/function/llm-records/page.tsx
+++ b/web/src/app/(main)/function/llm-records/page.tsx
@@ -8,8 +8,20 @@ import {
ChevronRightIcon,
Loader2Icon,
XIcon,
+ Trash2Icon,
} from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -32,7 +44,7 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
-import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types";
+import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -85,6 +97,13 @@ export default function LLMRecordsPage() {
const [detail, setDetail] = React.useState(null);
const [detailLoading, setDetailLoading] = React.useState(false);
+ // Per-task delete (task picker + confirm dialog)
+ const [tasks, setTasks] = React.useState([]);
+ const [pickedTask, setPickedTask] = React.useState("");
+ const [deleteOpen, setDeleteOpen] = React.useState(false);
+ const [deleting, setDeleting] = React.useState(false);
+ const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
+
// Load recording toggle state on mount.
React.useEffect(() => {
let alive = true;
@@ -117,14 +136,14 @@ export default function LLMRecordsPage() {
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
- }, [sessionQ, model, size]);
+ }, [sessionQ, model, size, pickedTask]);
// Load list.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
- .llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size })
+ .llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
@@ -132,8 +151,29 @@ export default function LLMRecordsPage() {
})
.catch(() => {})
.finally(() => alive && setLoading(false));
+ api
+ .llmTasks()
+ .then((r) => { if (alive) setTasks(r.tasks ?? []); })
+ .catch(() => {});
return () => { alive = false; };
- }, [page, size, sessionQ, model]);
+ }, [page, size, sessionQ, model, pickedTask, reloadTick]);
+
+ // Delete every LLM record for the picked task, then refetch.
+ const confirmDelete = () => {
+ setDeleting(true);
+ api
+ .llmRecordsDeleteTask(pickedTask)
+ .then(() => {
+ setDeleteOpen(false);
+ setSelected(null);
+ setDetail(null);
+ setPickedTask("");
+ setPage(0);
+ setReloadTick((t) => t + 1);
+ })
+ .catch(() => {})
+ .finally(() => setDeleting(false));
+ };
// Lazy-load full request/response when a row is selected.
React.useEffect(() => {
@@ -184,6 +224,36 @@ export default function LLMRecordsPage() {
value={model}
onChange={(e) => setModel(e.target.value)}
/>
+
+