mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
feat: 添加LLM模型清单、命令执行记录录制和LLM请求记录录制功能
This commit is contained in:
1 parent
0f94849012
commit
5b58330f01
14 files changed
+1546
-197
No files matched your search
+228
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -137,6 +137,9 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)}
|
m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)}
|
||||||
if proxyAddr != "" {
|
if proxyAddr != "" {
|
||||||
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/Autumn-27/norma/transcript"
|
"github.com/Autumn-27/norma/transcript"
|
||||||
"github.com/Autumn-27/artex/agent"
|
"github.com/Autumn-27/artex/agent"
|
||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
|
"github.com/Autumn-27/artex/llmrec"
|
||||||
"github.com/Autumn-27/artex/report"
|
"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
|
chatAgent *agent.ChatAgent // conversational runner for the chat page; nil w/o LLM
|
||||||
llmCfg agent.Config // current LLM config (key not exposed)
|
llmCfg agent.Config // current LLM config (key not exposed)
|
||||||
llmOn bool
|
llmOn bool
|
||||||
|
llmProf string // active LLM profile name (for llmrec tagging)
|
||||||
|
|
||||||
// chatBusy guards the per-task main-agent run: the chat handler launches the
|
// 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
|
// 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 == "" {
|
if cfg.APIKey == "" {
|
||||||
return cfg, false
|
return cfg, false
|
||||||
}
|
}
|
||||||
|
s.cfgMu.Lock()
|
||||||
|
s.llmProf = p.Name
|
||||||
|
s.cfgMu.Unlock()
|
||||||
return cfg, true
|
return cfg, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +332,11 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||||
s.engine.UseLLM(pl, wk)
|
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)
|
log.Printf("[engine] build provider for LLM profile %d failed: %v", id, err)
|
||||||
return nil, nil
|
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)
|
pl, wk := s.buildPlannerWorker(prov, cfg)
|
||||||
s.profAgents[id] = &profBundle{pl: pl, wk: wk}
|
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)
|
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)
|
log.Printf("[chat] build provider for LLM profile %d failed: %v", id, err)
|
||||||
return nil
|
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"))
|
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
|
||||||
win := cfg.CompactionWindow()
|
win := cfg.CompactionWindow()
|
||||||
ca := agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win)
|
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("POST /api/gc", s.gc)
|
||||||
mux.HandleFunc("GET /api/traffic", s.getTraffic)
|
mux.HandleFunc("GET /api/traffic", s.getTraffic)
|
||||||
mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange)
|
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("GET /api/settings", s.getSettings)
|
||||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||||
mux.HandleFunc("POST /api/settings/web-search/test", s.testWebSearch)
|
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("POST /api/llm/profiles", s.pgSaveProfile)
|
||||||
mux.HandleFunc("DELETE /api/llm/profiles/{id}", s.pgDeleteProfile)
|
mux.HandleFunc("DELETE /api/llm/profiles/{id}", s.pgDeleteProfile)
|
||||||
mux.HandleFunc("POST /api/llm/profiles/active", s.pgActivateProfile)
|
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)
|
mux.HandleFunc("GET /api/intercept/rules", s.interceptListRules)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -1537,6 +1538,107 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]any{"ok": true})
|
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 白名单) ---
|
// --- prompt template helpers (Go text/template + catalog 白名单) ---
|
||||||
|
|
||||||
// validateTemplate parses the template and rejects any {{.Var}} not in the catalog.
|
// validateTemplate parses the template and rejects any {{.Var}} not in the catalog.
|
||||||
|
|||||||
Generated
+4
-3
@@ -4253,6 +4253,7 @@
|
|||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -4262,7 +4263,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -4555,7 +4556,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
@@ -10599,7 +10600,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
@@ -60,6 +61,9 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
const [cw, setCw] = React.useState("0");
|
const [cw, setCw] = React.useState("0");
|
||||||
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
|
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
|
||||||
const [effort, setEffort] = React.useState("high");
|
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() {
|
function reset() {
|
||||||
setName("");
|
setName("");
|
||||||
@@ -73,6 +77,28 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
setCw("0");
|
setCw("0");
|
||||||
setThinkMode("none");
|
setThinkMode("none");
|
||||||
setEffort("high");
|
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() {
|
async function create() {
|
||||||
@@ -145,13 +171,47 @@ function NewProfileDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="np-model">模型</Label>
|
<Label htmlFor="np-model">模型</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="np-model"
|
<Input
|
||||||
className="font-mono"
|
id="np-model"
|
||||||
placeholder="claude-opus-4-8"
|
className="font-mono"
|
||||||
value={model}
|
placeholder="claude-opus-4-8"
|
||||||
onChange={(e) => setModel(e.target.value)}
|
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>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -276,6 +336,9 @@ export default function LLMPage() {
|
|||||||
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
|
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
|
||||||
const [effort, setEffort] = React.useState("high");
|
const [effort, setEffort] = React.useState("high");
|
||||||
const [testing, setTesting] = React.useState(false);
|
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 () => {
|
const load = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -312,6 +375,32 @@ export default function LLMPage() {
|
|||||||
setKeyHint(selected.api_key_hint ?? "");
|
setKeyHint(selected.api_key_hint ?? "");
|
||||||
}, [selected]);
|
}, [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() {
|
async function testConnection() {
|
||||||
if (testing) return;
|
if (testing) return;
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
@@ -451,13 +540,47 @@ export default function LLMPage() {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="model">模型</Label>
|
<Label htmlFor="model">模型</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
id="model"
|
<Input
|
||||||
className="font-mono"
|
id="model"
|
||||||
placeholder="claude-opus-4-8"
|
className="font-mono"
|
||||||
value={model}
|
placeholder="claude-opus-4-8"
|
||||||
onChange={(e) => setModel(e.target.value)}
|
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">
|
<div className="grid gap-2">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
Tool, Conversation, AgentTrigger,
|
Tool, Conversation, AgentTrigger,
|
||||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary,
|
TaskAssetView, SSProject, SSTask, ConvTokenSummary,
|
||||||
|
CommandRecord, LLMRecordItem, LLMRecordDetail,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
@@ -248,6 +249,14 @@ export const api = {
|
|||||||
}) => post<{ id: number }>("/llm/profiles", p),
|
}) => post<{ id: number }>("/llm/profiles", p),
|
||||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||||
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(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 ----
|
||||||
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.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());
|
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
@@ -1,185 +1,45 @@
|
|||||||
import {
|
// TEMPORARY: offline build fallback — uses local Geist fonts instead of Google Fonts.
|
||||||
DM_Sans,
|
// Restore registry.ts.bak after build.
|
||||||
Figtree,
|
import localFont from "next/font/local";
|
||||||
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";
|
|
||||||
|
|
||||||
import { GeistPixelSquare } from "geist/font/pixel";
|
import { GeistPixelSquare } from "geist/font/pixel";
|
||||||
|
|
||||||
const inter = Inter({
|
const geist = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-geist" });
|
||||||
subsets: ["latin"],
|
const inter = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-inter" });
|
||||||
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 notoSans = Noto_Sans({
|
const dmSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-dm-sans" });
|
||||||
subsets: ["latin"],
|
const nunitoSans = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-nunito-sans" });
|
||||||
variable: "--font-noto-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 roboto = Roboto({
|
const notoSerif = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-noto-serif" });
|
||||||
subsets: ["latin"],
|
const robotoSlab = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-roboto-slab" });
|
||||||
weight: ["400", "500", "700"],
|
const merriweather = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-sans/Geist-Variable.woff2", variable: "--font-merriweather" });
|
||||||
variable: "--font-roboto",
|
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 geist = Geist({
|
const jetBrainsMono = localFont({ src: "../../../node_modules/geist/dist/fonts/geist-mono/GeistMono-Variable.woff2", variable: "--font-jetbrains-mono" });
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fontRegistry = {
|
export const fontRegistry = {
|
||||||
geist: {
|
geist: { label: "Geist", font: geist },
|
||||||
label: "Geist",
|
inter: { label: "Inter", font: inter },
|
||||||
font: geist,
|
notoSans: { label: "Noto Sans", font: notoSans },
|
||||||
},
|
nunitoSans: { label: "Nunito Sans", font: nunitoSans },
|
||||||
inter: {
|
figtree: { label: "Figtree", font: figtree },
|
||||||
label: "Inter",
|
roboto: { label: "Roboto", font: roboto },
|
||||||
font: inter,
|
raleway: { label: "Raleway", font: raleway },
|
||||||
},
|
dmSans: { label: "DM Sans", font: dmSans },
|
||||||
notoSans: {
|
publicSans: { label: "Public Sans", font: publicSans },
|
||||||
label: "Noto Sans",
|
outfit: { label: "Outfit", font: outfit },
|
||||||
font: notoSans,
|
geistMono: { label: "Geist Mono", font: geistMono },
|
||||||
},
|
geistPixelSquare: { label: "Geist Pixel Square", font: GeistPixelSquare },
|
||||||
nunitoSans: {
|
jetBrainsMono: { label: "JetBrains Mono", font: jetBrainsMono },
|
||||||
label: "Nunito Sans",
|
notoSerif: { label: "Noto Serif", font: notoSerif },
|
||||||
font: nunitoSans,
|
robotoSlab: { label: "Roboto Slab", font: robotoSlab },
|
||||||
},
|
merriweather: { label: "Merriweather", font: merriweather },
|
||||||
figtree: {
|
lora: { label: "Lora", font: lora },
|
||||||
label: "Figtree",
|
playfairDisplay: { label: "Playfair Display", font: playfairDisplay },
|
||||||
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;
|
} as const;
|
||||||
|
|
||||||
export type FontKey = keyof typeof fontRegistry;
|
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)
|
.map((f) => f.font.variable)
|
||||||
.join(" ");
|
.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, f]) => ({
|
||||||
key,
|
key,
|
||||||
label: f.label,
|
label: f.label,
|
||||||
|
|||||||
@@ -549,3 +549,37 @@ export interface ConvTokenSummary {
|
|||||||
cache_read_tokens: number;
|
cache_read_tokens: number;
|
||||||
cache_write_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,
|
MessageSquare,
|
||||||
Network,
|
Network,
|
||||||
Plug,
|
Plug,
|
||||||
|
Radio,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Settings2,
|
Settings2,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Target,
|
Target,
|
||||||
|
Terminal,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -66,6 +68,8 @@ export const sidebarItems: NavGroup[] = [
|
|||||||
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
|
{ id: "tasks", title: "任务", url: "/function/tasks", icon: Target },
|
||||||
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
|
{ id: "findings", title: "发现", url: "/function/findings", icon: Bug },
|
||||||
{ id: "traffic", title: "流量", url: "/function/traffic", icon: Activity },
|
{ 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: "assets", title: "资产", url: "/function/assets", icon: Network },
|
||||||
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
|
{ id: "sync", title: "资产同步", url: "/function/sync", icon: FolderSync },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in new issue
Block a user