feat: 添加LLM模型清单、命令执行记录录制和LLM请求记录录制功能

This commit is contained in:
lornlee committed 2026-07-30 15:18:40 +08:00
1 parent 0f94849012
commit 5b58330f01
14 files changed
+1546 -197

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
package db
import (
"database/sql"
"fmt"
"time"
)
// CommandRecord is a paired Bash tool_use + tool_result from the activity table.
type CommandRecord struct {
ID int64 `json:"id"`
ExpID int64 `json:"exploration_id"`
Worker string `json:"worker"`
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.
func (d *DB) ListCommands(expID *int64, q string, page, size int) ([]CommandRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
where := `WHERE u.tool = 'Bash' AND u.kind = 'tool_use'`
args := []any{}
argN := 1
if expID != nil {
where += fmt.Sprintf(` AND u.exploration_id = $%d`, argN)
args = append(args, *expID)
argN++
}
if q != "" {
where += fmt.Sprintf(` AND u.detail ILIKE $%d`, argN)
args = append(args, "%"+q+"%")
argN++
}
// count
var total int
countQ := `SELECT COUNT(*) FROM activity u ` + where
if err := d.QueryRow(countQ, args...).Scan(&total); err != nil {
return nil, 0, err
}
// data query: join tool_use with its tool_result
dataQ := `
SELECT u.id, u.exploration_id, COALESCE(u.worker,''), 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'
` + where + `
ORDER BY u.id DESC
LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1)
args = append(args, size, offset)
rows, err := d.Query(dataQ, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
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 {
return nil, 0, err
}
out = append(out, c)
}
return out, total, rows.Err()
}
// LLMRecord is one recorded LLM API call (request + response).
type LLMRecord struct {
ID int64 `json:"id"`
Ts time.Time `json:"ts"`
Model string `json:"model"`
ProfileName string `json:"profile_name"`
SessionID string `json:"session_id"`
TaskID string `json:"task_id"`
Worker string `json:"worker"`
LatencyMs int `json:"latency_ms"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheRead int `json:"cache_read"`
CacheWrite int `json:"cache_write"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
RequestBody string `json:"request_body,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
}
const llmRecordsSchema = `
CREATE TABLE IF NOT EXISTS llm_records (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ DEFAULT now(),
model TEXT,
profile_name TEXT,
session_id TEXT,
task_id TEXT,
worker TEXT,
latency_ms INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cache_read INTEGER,
cache_write INTEGER,
status TEXT,
error TEXT,
request_body TEXT,
response_body TEXT
);
CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts);
CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id);
`
// llmRecordsMigrate adds new columns to existing tables.
const llmRecordsMigrate = `
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT;
`
// EnsureLLMRecordsTable creates the llm_records table if it does not exist.
func (d *DB) EnsureLLMRecordsTable() error {
if _, err := d.Exec(llmRecordsSchema); err != nil {
return err
}
_, err := d.Exec(llmRecordsMigrate)
return err
}
// InsertLLMRecord stores one LLM call record.
func (d *DB) InsertLLMRecord(r *LLMRecord) error {
_, err := d.Exec(`
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker),
r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite,
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody))
return err
}
// ListLLMRecords returns paginated LLM records with optional filters.
func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord, int, error) {
if size <= 0 {
size = 50
}
if page < 0 {
page = 0
}
offset := page * size
where := `WHERE true`
args := []any{}
argN := 1
if model != "" {
where += fmt.Sprintf(` AND model = $%d`, argN)
args = append(args, model)
argN++
}
if session != "" {
where += fmt.Sprintf(` AND session_id ILIKE $%d`, argN)
args = append(args, "%"+session+"%")
argN++
}
var total int
if err := d.QueryRow(`SELECT COUNT(*) FROM llm_records `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
dataQ := `SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
COALESCE(status,''), COALESCE(error,'')
FROM llm_records ` + where + ` ORDER BY id DESC LIMIT $` + fmt.Sprintf("%d", argN) + ` OFFSET $` + fmt.Sprintf("%d", argN+1)
args = append(args, size, offset)
rows, err := d.Query(dataQ, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := []LLMRecord{}
for rows.Next() {
var r LLMRecord
if err := rows.Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error); err != nil {
return nil, 0, err
}
out = append(out, r)
}
return out, total, rows.Err()
}
// GetLLMRecord returns a single LLM record with full request/response bodies.
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
var r LLMRecord
var reqBody, respBody sql.NullString
err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
COALESCE(status,''), COALESCE(error,''), request_body, response_body
FROM llm_records WHERE id=$1`, id).
Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error,
&reqBody, &respBody)
if err != nil {
return nil, err
}
r.RequestBody = reqBody.String
r.ResponseBody = respBody.String
return &r, nil
}
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
+211
View File
@@ -0,0 +1,211 @@
// Package llmrec implements a recording decorator for llm.Provider. It intercepts
// every Stream call, captures the full request and accumulated response, and
// persists them to PostgreSQL for later inspection.
package llmrec
import (
"context"
"encoding/json"
"iter"
"log"
"strings"
"sync"
"time"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/artex/db"
)
// 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)
mu sync.Mutex
sessionID string // current session context (set by caller)
taskID string // exploration/task identifier
worker string // worker name (planner, work#1, mainagent, etc.)
}
// 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}
}
// SetSession sets the session identifier for subsequent calls (e.g. "exp1-worker-i3").
// It also derives taskID and worker from the session format "exp<N>-<role>...".
func (r *Recorder) SetSession(id string) {
r.mu.Lock()
r.sessionID = id
r.taskID, r.worker = parseSession(id)
r.mu.Unlock()
}
// parseSession extracts task id and worker role from session strings like
// "exp1-worker-i3" → ("1", "worker") or "exp2-planner" → ("2", "planner").
func parseSession(s string) (taskID, worker string) {
// format: exp<N>-<role>[-suffix]
if !strings.HasPrefix(s, "exp") {
return "", ""
}
rest := s[3:] // after "exp"
// split task number
i := strings.IndexByte(rest, '-')
if i < 0 {
return rest, ""
}
taskID = rest[:i]
rest = rest[i+1:]
// worker role is up to the next '-' (e.g. "worker" from "worker-i3")
if j := strings.IndexByte(rest, '-'); j >= 0 {
worker = rest[:j]
} else {
worker = rest
}
return taskID, worker
}
func (r *Recorder) getContext() (session, taskID, worker string) {
r.mu.Lock()
defer r.mu.Unlock()
return r.sessionID, r.taskID, r.worker
}
// Stream implements llm.Provider. It delegates to the inner provider, accumulates
// the streamed events to reconstruct the response, and records the full exchange.
func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
start := time.Now()
session, taskID, worker := r.getContext()
// Serialize the request for storage.
reqBody := serializeRequest(req)
return func(yield func(llm.StreamEvent, error) bool) {
var (
textBuf strings.Builder
thinkingBuf strings.Builder
usage llm.Usage
stopReason string
streamErr error
)
for ev, err := range r.inner.Stream(ctx, req) {
if err != nil {
streamErr = err
// Record the failed call, then propagate.
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, "", err)
if !yield(ev, err) {
return
}
return
}
// Accumulate response content.
switch ev.Type {
case llm.SETextDelta:
textBuf.WriteString(ev.Text)
case llm.SEThinkingDelta:
thinkingBuf.WriteString(ev.Text)
case llm.SEMessageDelta:
if ev.StopReason != "" {
stopReason = ev.StopReason
}
usage = ev.Usage
case llm.SEMessageStart:
if ev.Usage.InputTokens > 0 {
usage = ev.Usage
}
}
if !yield(ev, err) {
return
}
}
// Stream completed normally — record it.
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, streamErr)
}
}
// record persists one LLM call to PostgreSQL (async, best-effort).
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
latency := int(time.Since(start).Milliseconds())
status := "ok"
errMsg := ""
if streamErr != nil {
status = "error"
errMsg = streamErr.Error()
}
// Build response body JSON.
resp := map[string]any{
"text": text,
"stop_reason": stopReason,
"usage": map[string]int{
"input_tokens": usage.InputTokens,
"output_tokens": usage.OutputTokens,
"cache_read_tokens": usage.CacheReadTokens,
"cache_write_tokens": usage.CacheWriteTokens,
},
}
if thinking != "" {
resp["thinking"] = thinking
}
respBody, _ := json.Marshal(resp)
model := ""
if len(req.System) > 0 {
// model is not in CompletionRequest; use the configured model name
}
model = r.model
rec := &db.LLMRecord{
SessionID: session,
TaskID: taskID,
Worker: worker,
Model: model,
ProfileName: r.prof,
LatencyMs: latency,
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
CacheRead: usage.CacheReadTokens,
CacheWrite: usage.CacheWriteTokens,
Status: status,
Error: errMsg,
RequestBody: reqBody,
ResponseBody: string(respBody),
}
go func() {
if err := r.pg.InsertLLMRecord(rec); err != nil {
log.Printf("[llmrec] insert: %v", err)
}
}()
}
// serializeRequest builds a JSON representation of the completion request.
func serializeRequest(req llm.CompletionRequest) string {
m := map[string]any{
"system": req.System,
"messages": req.Messages,
"max_tokens": req.MaxTokens,
}
if len(req.Tools) > 0 {
// Store tool names only (full schemas are huge).
names := make([]string, len(req.Tools))
for i, t := range req.Tools {
names[i] = t.Name
}
m["tools"] = names
m["tools_count"] = len(req.Tools)
}
if req.Temperature != nil {
m["temperature"] = *req.Temperature
}
if len(req.Stop) > 0 {
m["stop"] = req.Stop
}
b, _ := json.Marshal(m)
return string(b)
}
+59
View File
@@ -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)
}
+3
View File
@@ -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)
+22
View File
@@ -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)
+102
View File
@@ -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.
+4 -3
View File
@@ -4253,6 +4253,7 @@
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -4262,7 +4263,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -4555,7 +4556,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
@@ -10599,7 +10600,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -0,0 +1,307 @@
"use client";
import * as React from "react";
import {
TerminalIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { CommandRecord } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// 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 {
try {
const obj = JSON.parse(raw);
if (obj && typeof obj.command === "string") return obj.command;
} catch { /* not JSON */ }
return raw;
}
// truncate clips a string to maxLen characters.
function truncate(s: string, maxLen: number): string {
const first = s.split("\n")[0]; // single-line preview
if (first.length <= maxLen) return first;
return first.slice(0, maxLen) + "…";
}
const PAGE_SIZES = [25, 50, 100];
const CMD_MAX_LEN = 80;
export default function CommandsPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [query, setQuery] = React.useState("");
const [queryQ, setQueryQ] = React.useState("");
const [taskFilter, setTaskFilter] = React.useState("");
const [commands, setCommands] = React.useState<CommandRecord[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = React.useState(false);
// Inline detail panel (like traffic page, not a dialog)
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
// Debounce search input.
React.useEffect(() => {
const t = setTimeout(() => setQueryQ(query.trim()), 300);
return () => clearTimeout(t);
}, [query]);
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
}, [queryQ, taskFilter, size]);
// Load data.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.commands({ task: taskFilter || undefined, q: queryQ || undefined, page, size })
.then((r) => {
if (!alive) return;
setCommands(r.commands ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
}, [page, size, queryQ, taskFilter]);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + commands.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<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>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<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="搜索命令..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input
placeholder="任务 ID"
className="h-8 w-28"
value={taskFilter}
onChange={(e) => setTaskFilter(e.target.value.replace(/\D/g, ""))}
/>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">时间</TableHead>
<TableHead className="w-[60px]">任务</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead>命令</TableHead>
<TableHead className="w-[60px]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && commands.length === 0 ? (
<TableRow>
<TableCell colSpan={5} 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>
</TableRow>
) : (
commands.map((cmd) => (
<TableRow
key={cmd.id}
className={cn(
"cursor-pointer",
selected?.id === cmd.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(cmd)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(cmd.created_at)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
#{cmd.exploration_id}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{cmd.worker || "-"}
</Badge>
</TableCell>
<TableCell className="max-w-0">
<code className="block truncate font-mono text-xs">
{truncate(extractCommand(cmd.command), CMD_MAX_LEN)}
</code>
</TableCell>
<TableCell>
{cmd.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.exploration_id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.worker || "-"}
</Badge>
<span className="text-xs text-muted-foreground">
{fmtTime(selected.created_at)}
</span>
{selected.is_error ? (
<Badge variant="destructive" className="text-xs">失败</Badge>
) : (
<Badge variant="secondary" className="text-xs text-emerald-600">成功</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Input / Output split */}
<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
</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)}
</pre>
</div>
</div>
<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">
输出 Output
</div>
<div className="min-h-0 flex-1 overflow-auto">
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.is_error && "text-red-600 dark:text-red-400",
)}>
{selected.output || "(空)"}
</pre>
</div>
</div>
</div>
</Card>
)}
</div>
</div>
);
}
@@ -0,0 +1,365 @@
"use client";
import * as React from "react";
import {
RadioIcon,
SearchIcon,
ChevronLeftIcon,
ChevronRightIcon,
Loader2Icon,
XIcon,
} from "lucide-react";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function fmtLatency(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtTokens(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function tryFormatJSON(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return s;
}
}
const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
const [session, setSession] = React.useState("");
const [sessionQ, setSessionQ] = React.useState("");
const [model, setModel] = React.useState("");
const [records, setRecords] = React.useState<LLMRecordItem[]>([]);
const [total, setTotal] = React.useState(0);
const [loading, setLoading] = 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);
// Debounce session filter.
React.useEffect(() => {
const t = setTimeout(() => setSessionQ(session.trim()), 300);
return () => clearTimeout(t);
}, [session]);
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
}, [sessionQ, model, size]);
// Load list.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
setTotal(r.total ?? 0);
})
.catch(() => {})
.finally(() => alive && setLoading(false));
return () => { alive = false; };
}, [page, size, sessionQ, model]);
// Lazy-load full request/response when a row is selected.
React.useEffect(() => {
if (!selected) {
setDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setDetail(null);
api
.llmRecordDetail(selected.id)
.then((d) => { if (alive) setDetail(d); })
.catch(() => {})
.finally(() => { if (alive) setDetailLoading(false); });
return () => { alive = false; };
}, [selected]);
const totalPages = Math.max(1, Math.ceil(total / size));
const rangeStart = total === 0 ? 0 : page * size + 1;
const rangeEnd = page * size + records.length;
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<RadioIcon className="h-5 w-5 text-muted-foreground" />
<h1 className="text-xl font-semibold tracking-tight">LLM 录制</h1>
<Badge variant="secondary">{total}</Badge>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<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="搜索 Session ID..."
value={session}
onChange={(e) => setSession(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Input
placeholder="Model"
className="h-8 w-48"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* History table + inline detail (Burp-style split) */}
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-[130px]">时间</TableHead>
<TableHead className="w-[60px]">任务</TableHead>
<TableHead className="w-[90px]">Worker</TableHead>
<TableHead className="w-[100px]">Profile</TableHead>
<TableHead className="w-[140px]">Model</TableHead>
<TableHead className="w-[70px]">延迟</TableHead>
<TableHead className="w-[90px]">Tokens</TableHead>
<TableHead className="w-[60px]">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center">
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
</TableCell>
</TableRow>
) : records.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
暂无 LLM 调用记录
</TableCell>
</TableRow>
) : (
records.map((rec) => (
<TableRow
key={rec.id}
className={cn(
"cursor-pointer",
selected?.id === rec.id && "bg-accent hover:bg-accent",
)}
onClick={() => setSelected(rec)}
>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{fmtTime(rec.ts)}
</TableCell>
<TableCell className="text-xs font-mono text-muted-foreground">
{rec.task_id ? `#${rec.task_id}` : "-"}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs font-mono">
{rec.worker || "-"}
</Badge>
</TableCell>
<TableCell>
<span className="text-xs">{rec.profile_name || "-"}</span>
</TableCell>
<TableCell>
<span className="text-xs font-mono">{rec.model || "-"}</span>
</TableCell>
<TableCell>
<span className={cn("text-xs", rec.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(rec.latency_ms)}
</span>
</TableCell>
<TableCell>
<span className="text-xs">
{fmtTokens(rec.input_tokens)} / {fmtTokens(rec.output_tokens)}
</span>
</TableCell>
<TableCell>
{rec.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</Card>
{/* Inline detail panel */}
{selected && (
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
{/* Detail header */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Badge variant="outline" className="text-xs font-mono">
#{selected.id}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.profile_name || "-"}
</Badge>
<Badge variant="outline" className="text-xs font-mono">
{selected.model || "-"}
</Badge>
{selected.task_id && (
<Badge variant="outline" className="text-xs font-mono">
任务 #{selected.task_id}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{fmtTime(selected.ts)}
</span>
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
{fmtLatency(selected.latency_ms)}
</span>
{selected.status === "ok" ? (
<Badge variant="secondary" className="text-xs text-emerald-600">OK</Badge>
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
</Button>
</div>
{/* Request / Response split */}
<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">
Request
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载…
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
</pre>
)}
</div>
</div>
<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">
Response
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载…
</div>
) : (
<pre className={cn(
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}>
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
</pre>
)}
</div>
</div>
</div>
</Card>
)}
</div>
</div>
);
}
+138 -15
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { Loader2Icon, PlugZapIcon, PlusIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { Loader2Icon, PlugZapIcon, PlusIcon, RefreshCwIcon, SaveIcon, StarIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api } from "@/lib/api";
@@ -60,6 +61,9 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
const [cw, setCw] = React.useState("0");
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
function reset() {
setName("");
@@ -73,6 +77,28 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
setCw("0");
setThinkMode("none");
setEffort("high");
setModels([]);
setModelsOpen(false);
}
async function loadModels() {
if (loadingModels) return;
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy);
if (r.ok && r.models && r.models.length > 0) {
setModels(r.models);
setModelsOpen(true);
toast.success(`已加载 ${r.models.length} 个模型`);
} else {
toast.error(`加载模型失败:${r.error ?? "未获取到模型"}`);
}
} catch (e) {
toast.error(`加载模型出错:${(e as Error).message}`);
} finally {
setLoadingModels(false);
}
}
async function create() {
@@ -145,13 +171,47 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
</div>
<div className="grid gap-2">
<Label htmlFor="np-model">模型</Label>
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<div className="flex gap-2">
<Input
id="np-model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Popover open={modelsOpen} onOpenChange={setModelsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={loadingModels}
onClick={loadModels}
title="从 API 加载可用模型"
>
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
</Button>
</PopoverTrigger>
{models.length > 0 && (
<PopoverContent className="w-72 max-h-64 overflow-y-auto p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setModel(m);
setModelsOpen(false);
}}
>
{m}
</button>
))}
</PopoverContent>
)}
</Popover>
</div>
</div>
</div>
<div className="grid gap-2">
@@ -276,6 +336,9 @@ export default function LLMPage() {
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [testing, setTesting] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
const [loadingModels, setLoadingModels] = React.useState(false);
const [modelsOpen, setModelsOpen] = React.useState(false);
const load = React.useCallback(async () => {
try {
@@ -312,6 +375,32 @@ export default function LLMPage() {
setKeyHint(selected.api_key_hint ?? "");
}, [selected]);
async function loadModels() {
if (loadingModels) return;
setLoadingModels(true);
setModels([]);
try {
const r = await api.fetchLLMModels(
format,
baseUrl,
apiKey,
proxy,
selectedId ? Number(selectedId) : undefined,
);
if (r.ok && r.models && r.models.length > 0) {
setModels(r.models);
setModelsOpen(true);
toast.success(`已加载 ${r.models.length} 个模型`);
} else {
toast.error(`加载模型失败:${r.error ?? "未获取到模型"}`);
}
} catch (e) {
toast.error(`加载模型出错:${(e as Error).message}`);
} finally {
setLoadingModels(false);
}
}
async function testConnection() {
if (testing) return;
setTesting(true);
@@ -451,13 +540,47 @@ export default function LLMPage() {
<div className="grid gap-2">
<Label htmlFor="model">模型</Label>
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<div className="flex gap-2">
<Input
id="model"
className="font-mono"
placeholder="claude-opus-4-8"
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Popover open={modelsOpen} onOpenChange={setModelsOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
disabled={loadingModels}
onClick={loadModels}
title="从 API 加载可用模型"
>
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
</Button>
</PopoverTrigger>
{models.length > 0 && (
<PopoverContent className="w-72 max-h-64 overflow-y-auto p-1" align="end">
{models.map((m) => (
<button
key={m}
type="button"
className="w-full rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setModel(m);
setModelsOpen(false);
}}
>
{m}
</button>
))}
</PopoverContent>
)}
</Popover>
</div>
</div>
<div className="grid gap-2">
+30
View File
@@ -12,6 +12,7 @@ import type {
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary,
CommandRecord, LLMRecordItem, LLMRecordDetail,
} from "@/lib/types";
function getToken(): string | null {
@@ -248,6 +249,14 @@ export const api = {
}) => post<{ id: number }>("/llm/profiles", p),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
provider,
base_url,
api_key,
proxy,
profile_id,
}),
// ---- agents ----
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
@@ -440,4 +449,25 @@ export const api = {
});
if (!r.ok) throw new Error(await r.text());
},
// ---- commands (Bash execution history) ----
commands: (params?: { task?: string; q?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.task) sp.set("task", params.task);
if (params?.q) sp.set("q", params.q);
sp.set("page", String(params?.page ?? 0));
sp.set("size", String(params?.size ?? 50));
return get<{ commands: CommandRecord[]; total: number }>(`/commands?${sp}`);
},
// ---- LLM records ----
llmRecords: (params?: { model?: string; session?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.model) sp.set("model", params.model);
if (params?.session) sp.set("session", params.session);
sp.set("page", String(params?.page ?? 0));
sp.set("size", String(params?.size ?? 50));
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
},
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
};
+39 -179
View File
@@ -1,185 +1,45 @@
import {
DM_Sans,
Figtree,
Geist,
Geist_Mono,
Inter,
JetBrains_Mono,
Lora,
Merriweather,
Noto_Sans,
Noto_Serif,
Nunito_Sans,
Outfit,
Playfair_Display,
Public_Sans,
Raleway,
Roboto,
Roboto_Slab,
} from "next/font/google";
// TEMPORARY: offline build fallback — uses local Geist fonts instead of Google Fonts.
// Restore registry.ts.bak after build.
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
const notoSans = Noto_Sans({
subsets: ["latin"],
variable: "--font-noto-sans",
});
const roboto = Roboto({
subsets: ["latin"],
weight: ["400", "500", "700"],
variable: "--font-roboto",
});
const geist = Geist({
subsets: ["latin"],
variable: "--font-geist",
});
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
const dmSans = DM_Sans({
subsets: ["latin"],
variable: "--font-dm-sans",
});
const nunitoSans = Nunito_Sans({
subsets: ["latin"],
variable: "--font-nunito-sans",
});
const figtree = Figtree({
subsets: ["latin"],
variable: "--font-figtree",
});
const raleway = Raleway({
subsets: ["latin"],
variable: "--font-raleway",
});
const publicSans = Public_Sans({
subsets: ["latin"],
variable: "--font-public-sans",
});
const jetBrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains-mono",
});
const notoSerif = Noto_Serif({
subsets: ["latin"],
variable: "--font-noto-serif",
});
const robotoSlab = Roboto_Slab({
subsets: ["latin"],
variable: "--font-roboto-slab",
});
const merriweather = Merriweather({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-merriweather",
});
const lora = Lora({
subsets: ["latin"],
variable: "--font-lora",
});
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair-display",
});
const geist = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-geist" });
const inter = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-inter" });
const notoSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-noto-sans" });
const roboto = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-roboto" });
const outfit = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-outfit" });
const dmSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-dm-sans" });
const nunitoSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-nunito-sans" });
const figtree = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-figtree" });
const raleway = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-raleway" });
const publicSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-public-sans" });
const notoSerif = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-noto-serif" });
const robotoSlab = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-roboto-slab" });
const merriweather = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-merriweather" });
const lora = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-lora" });
const playfairDisplay = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-playfair-display" });
const geistMono = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2", variable: "--font-geist-mono" });
const jetBrainsMono = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2", variable: "--font-jetbrains-mono" });
export const fontRegistry = {
geist: {
label: "Geist",
font: geist,
},
inter: {
label: "Inter",
font: inter,
},
notoSans: {
label: "Noto Sans",
font: notoSans,
},
nunitoSans: {
label: "Nunito Sans",
font: nunitoSans,
},
figtree: {
label: "Figtree",
font: figtree,
},
roboto: {
label: "Roboto",
font: roboto,
},
raleway: {
label: "Raleway",
font: raleway,
},
dmSans: {
label: "DM Sans",
font: dmSans,
},
publicSans: {
label: "Public Sans",
font: publicSans,
},
outfit: {
label: "Outfit",
font: outfit,
},
geistMono: {
label: "Geist Mono",
font: geistMono,
},
geistPixelSquare: {
label: "Geist Pixel Square",
font: GeistPixelSquare,
},
jetBrainsMono: {
label: "JetBrains Mono",
font: jetBrainsMono,
},
notoSerif: {
label: "Noto Serif",
font: notoSerif,
},
robotoSlab: {
label: "Roboto Slab",
font: robotoSlab,
},
merriweather: {
label: "Merriweather",
font: merriweather,
},
lora: {
label: "Lora",
font: lora,
},
playfairDisplay: {
label: "Playfair Display",
font: playfairDisplay,
},
geist: { label: "Geist", font: geist },
inter: { label: "Inter", font: inter },
notoSans: { label: "Noto Sans", font: notoSans },
nunitoSans: { label: "Nunito Sans", font: nunitoSans },
figtree: { label: "Figtree", font: figtree },
roboto: { label: "Roboto", font: roboto },
raleway: { label: "Raleway", font: raleway },
dmSans: { label: "DM Sans", font: dmSans },
publicSans: { label: "Public Sans", font: publicSans },
outfit: { label: "Outfit", font: outfit },
geistMono: { label: "Geist Mono", font: geistMono },
geistPixelSquare: { label: "Geist Pixel Square", font: GeistPixelSquare },
jetBrainsMono: { label: "JetBrains Mono", font: jetBrainsMono },
notoSerif: { label: "Noto Serif", font: notoSerif },
robotoSlab: { label: "Roboto Slab", font: robotoSlab },
merriweather: { label: "Merriweather", font: merriweather },
lora: { label: "Lora", font: lora },
playfairDisplay: { label: "Playfair Display", font: playfairDisplay },
} as const;
export type FontKey = keyof typeof fontRegistry;
@@ -188,7 +48,7 @@ export const fontVars = (Object.values(fontRegistry) as Array<(typeof fontRegist
.map((f) => f.font.variable)
.join(" ");
export const fontOptions = (Object.entries(fontRegistry) as Array<[FontKey, (typeof fontRegistry)[FontKey]]>).map(
export const fontOptions = (Object.entries(fontRegistry) as [FontKey, { label: string; font: { variable: string } }][]).map(
([key, f]) => ({
key,
label: f.label,
+34
View File
@@ -549,3 +549,37 @@ export interface ConvTokenSummary {
cache_read_tokens: number;
cache_write_tokens: number;
}
// ---- Command recording (Bash execution history) ----
export interface CommandRecord {
id: number;
exploration_id: number;
worker: string;
command: string;
output: string;
is_error: boolean;
created_at: string;
}
// ---- LLM recording ----
export interface LLMRecordItem {
id: number;
ts: string;
model: string;
profile_name: string;
session_id: string;
task_id: string;
worker: string;
latency_ms: number;
input_tokens: number;
output_tokens: number;
cache_read: number;
cache_write: number;
status: string;
error?: string;
}
export interface LLMRecordDetail extends LLMRecordItem {
request_body: string;
response_body: string;
}
@@ -10,11 +10,13 @@ import {
MessageSquare,
Network,
Plug,
Radio,
ScrollText,
Settings2,
ShieldAlert,
Sparkles,
Target,
Terminal,
Wrench,
} from "lucide-react";
@@ -66,6 +68,8 @@ 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: "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 },
],