diff --git a/db/commands.go b/db/commands.go new file mode 100644 index 0000000..c9a81e9 --- /dev/null +++ b/db/commands.go @@ -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 +} diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go new file mode 100644 index 0000000..8101a54 --- /dev/null +++ b/llmrec/llmrec.go @@ -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-...". +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-[-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) +} diff --git a/server/commands.go b/server/commands.go new file mode 100644 index 0000000..cd8e8c2 --- /dev/null +++ b/server/commands.go @@ -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) +} diff --git a/server/manager.go b/server/manager.go index 2c74f32..346d31e 100644 --- a/server/manager.go +++ b/server/manager.go @@ -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) diff --git a/server/server.go b/server/server.go index e664384..a8a40e7 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 060d35e..4036892 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -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. diff --git a/web/package-lock.json b/web/package-lock.json index b5e8c63..493b756 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx new file mode 100644 index 0000000..d49959c --- /dev/null +++ b/web/src/app/(main)/function/commands/page.tsx @@ -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([]); + 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(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 ( +
+ {/* Header */} +
+
+ +

命令记录

+ {total} +
+
+ + {/* Toolbar */} +
+
+ + setQuery(e.target.value)} + className="h-8 pl-8" + /> +
+ setTaskFilter(e.target.value.replace(/\D/g, ""))} + /> + + +
+ + {rangeStart}–{rangeEnd} / {total} + + + + {page + 1} / {totalPages} + + +
+
+ + {/* History table + inline detail (Burp-style split) */} +
+ +
+ + + + 时间 + 任务 + Worker + 命令 + 状态 + + + + {loading && commands.length === 0 ? ( + + + + + + ) : commands.length === 0 ? ( + + + 暂无命令记录 + + + ) : ( + commands.map((cmd) => ( + setSelected(cmd)} + > + + {fmtTime(cmd.created_at)} + + + #{cmd.exploration_id} + + + + {cmd.worker || "-"} + + + + + {truncate(extractCommand(cmd.command), CMD_MAX_LEN)} + + + + {cmd.is_error ? ( + 失败 + ) : ( + 成功 + )} + + + )) + )} + +
+
+
+ + {/* Inline detail panel */} + {selected && ( + + {/* Detail header */} +
+ + #{selected.exploration_id} + + + {selected.worker || "-"} + + + {fmtTime(selected.created_at)} + + {selected.is_error ? ( + 失败 + ) : ( + 成功 + )} + +
+ {/* Input / Output split */} +
+
+
+ 输入 Command +
+
+
+                    {extractCommand(selected.command)}
+                  
+
+
+
+
+ 输出 Output +
+
+
+                    {selected.output || "(空)"}
+                  
+
+
+
+
+ )} +
+
+ ); +} diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx new file mode 100644 index 0000000..e8de9ee --- /dev/null +++ b/web/src/app/(main)/function/llm-records/page.tsx @@ -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([]); + 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(null); + const [detail, setDetail] = React.useState(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 ( +
+ {/* Header */} +
+
+ +

LLM 录制

+ {total} +
+
+ + {/* Toolbar */} +
+
+ + setSession(e.target.value)} + className="h-8 pl-8" + /> +
+ setModel(e.target.value)} + /> + + +
+ + {rangeStart}–{rangeEnd} / {total} + + + + {page + 1} / {totalPages} + + +
+
+ + {/* History table + inline detail (Burp-style split) */} +
+ +
+ + + + 时间 + 任务 + Worker + Profile + Model + 延迟 + Tokens + 状态 + + + + {loading && records.length === 0 ? ( + + + + + + ) : records.length === 0 ? ( + + + 暂无 LLM 调用记录 + + + ) : ( + records.map((rec) => ( + setSelected(rec)} + > + + {fmtTime(rec.ts)} + + + {rec.task_id ? `#${rec.task_id}` : "-"} + + + + {rec.worker || "-"} + + + + {rec.profile_name || "-"} + + + {rec.model || "-"} + + + 30000 && "text-amber-500")}> + {fmtLatency(rec.latency_ms)} + + + + + {fmtTokens(rec.input_tokens)} / {fmtTokens(rec.output_tokens)} + + + + {rec.status === "ok" ? ( + OK + ) : ( + Error + )} + + + )) + )} + +
+
+
+ + {/* Inline detail panel */} + {selected && ( + + {/* Detail header */} +
+ + #{selected.id} + + + {selected.profile_name || "-"} + + + {selected.model || "-"} + + {selected.task_id && ( + + 任务 #{selected.task_id} + + )} + + {fmtTime(selected.ts)} + + 30000 && "text-amber-500")}> + {fmtLatency(selected.latency_ms)} + + {selected.status === "ok" ? ( + OK + ) : ( + Error + )} + +
+ {/* Request / Response split */} +
+
+
+ Request +
+
+ {detailLoading ? ( +
+ + 加载… +
+ ) : ( +
+                      {detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
+                    
+ )} +
+
+
+
+ Response +
+
+ {detailLoading ? ( +
+ + 加载… +
+ ) : ( +
+                      {detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
+                    
+ )} +
+
+
+
+ )} +
+
+ ); +} diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 85e728d..85fbcd0 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -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("none"); const [effort, setEffort] = React.useState("high"); + const [models, setModels] = React.useState([]); + 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 }) {
- setModel(e.target.value)} - /> +
+ setModel(e.target.value)} + /> + + + + + {models.length > 0 && ( + + {models.map((m) => ( + + ))} + + )} + +
@@ -276,6 +336,9 @@ export default function LLMPage() { const [thinkMode, setThinkMode] = React.useState("none"); const [effort, setEffort] = React.useState("high"); const [testing, setTesting] = React.useState(false); + const [models, setModels] = React.useState([]); + 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() {
- setModel(e.target.value)} - /> +
+ setModel(e.target.value)} + /> + + + + + {models.length > 0 && ( + + {models.map((m) => ( + + ))} + + )} + +
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5c28fb5..09f3155 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/llm/records/${id}`), }; diff --git a/web/src/lib/fonts/registry.ts b/web/src/lib/fonts/registry.ts index 6cfe079..bc0e9ad 100644 --- a/web/src/lib/fonts/registry.ts +++ b/web/src/lib/fonts/registry.ts @@ -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, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5db88a8..1c31573 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; +} diff --git a/web/src/navigation/sidebar/sidebar-items.ts b/web/src/navigation/sidebar/sidebar-items.ts index 0b2dc87..fe699ad 100644 --- a/web/src/navigation/sidebar/sidebar-items.ts +++ b/web/src/navigation/sidebar/sidebar-items.ts @@ -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 }, ],