feat: implement proxy v2 with backend and frontend enhancements

- Enhanced proxy handlers and Anthropic service integration
- Improved SQLite storage and configuration
- Updated web UI and request handling

temp

WIP: additional changes
This commit is contained in:
Seif Ghazi 2025-07-07 02:07:37 -04:00
parent 20c25e2f2d
commit bd126e3d8a
No known key found for this signature in database
GPG key ID: 4519A4B1EEC1494E
8 changed files with 483 additions and 831 deletions

View file

@ -5,9 +5,7 @@ go 1.20
require ( require (
github.com/gorilla/handlers v1.5.2 github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1 github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.28
) )
require ( require github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
)

View file

@ -34,9 +34,9 @@ func Load() (*Config, error) {
cfg := &Config{ cfg := &Config{
Server: ServerConfig{ Server: ServerConfig{
Port: getEnv("PORT", "3001"), Port: getEnv("PORT", "3001"),
ReadTimeout: getDuration("READ_TIMEOUT", 500*time.Second), ReadTimeout: getDuration("READ_TIMEOUT", 600*time.Second), // Increased to 10 minutes
WriteTimeout: getDuration("WRITE_TIMEOUT", 500*time.Second), WriteTimeout: getDuration("WRITE_TIMEOUT", 600*time.Second), // Increased to 10 minutes
IdleTimeout: getDuration("IDLE_TIMEOUT", 500*time.Second), IdleTimeout: getDuration("IDLE_TIMEOUT", 600*time.Second), // Increased to 10 minutes
}, },
Anthropic: AnthropicConfig{ Anthropic: AnthropicConfig{
BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"), BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"),

View file

@ -40,80 +40,9 @@ func New(anthropicService service.AnthropicService, storageService service.Stora
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) { func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
log.Println("🤖 Chat completion request received (OpenAI format)") log.Println("🤖 Chat completion request received (OpenAI format)")
bodyBytes := getBodyBytes(r) // This endpoint is for compatibility but we're an Anthropic proxy
if bodyBytes == nil { // Return a helpful error message
http.Error(w, "Error reading request body", http.StatusBadRequest) writeErrorResponse(w, "This is an Anthropic proxy. Please use the /v1/messages endpoint instead of /v1/chat/completions", http.StatusBadRequest)
return
}
var req model.ChatCompletionRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
log.Printf("❌ Error parsing JSON: %v", err)
writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest)
return
}
requestID := generateRequestID()
startTime := time.Now()
requestLog := &model.RequestLog{
RequestID: requestID,
Timestamp: time.Now().Format(time.RFC3339),
Method: r.Method,
Endpoint: "/v1/chat/completions",
Headers: SanitizeHeaders(r.Header),
Body: req,
Model: req.Model,
UserAgent: r.Header.Get("User-Agent"),
ContentType: r.Header.Get("Content-Type"),
}
if _, err := h.storageService.SaveRequest(requestLog); err != nil {
log.Printf("❌ Error saving request: %v", err)
}
response := &model.ChatCompletionResponse{
ID: fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()),
Object: "chat.completion",
Created: time.Now().Unix(),
Model: req.Model,
Choices: []model.Choice{
{
Index: 0,
Message: model.ChatMessage{
Role: "assistant",
Content: "Hello! This is a test response from the refactored proxy server.",
},
FinishReason: "stop",
},
},
Usage: model.Usage{
PromptTokens: 10,
CompletionTokens: 20,
TotalTokens: 30,
},
}
if req.Model == "" {
response.Model = "claude-3-sonnet"
}
responseLog := &model.ResponseLog{
StatusCode: http.StatusOK,
Headers: SanitizeHeaders(w.Header()),
Body: response,
ResponseTime: time.Since(startTime).Milliseconds(),
IsStreaming: false,
}
// The requestLog object has the conversation details.
// We need to set the response on it and then save the update.
requestLog.Response = responseLog
if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil {
log.Printf("❌ Error updating request with response: %v", err)
}
writeJSONResponse(w, response)
} }
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
@ -132,18 +61,6 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
return return
} }
// Extract API key from incoming request headers
apiKey := r.Header.Get("x-api-key")
if apiKey == "" {
// Also check for X-Api-Key (capitalized version)
apiKey = r.Header.Get("X-Api-Key")
}
if apiKey == "" {
log.Println("❌ No API key provided in request headers")
writeErrorResponse(w, "API key required in x-api-key header", http.StatusUnauthorized)
return
}
requestID := generateRequestID() requestID := generateRequestID()
startTime := time.Now() startTime := time.Now()
@ -165,7 +82,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
} }
// Forward the request to Anthropic // Forward the request to Anthropic
resp, err := h.anthropicService.ForwardRequest(r.Context(), &req, apiKey) resp, err := h.anthropicService.ForwardRequest(r.Context(), r)
if err != nil { if err != nil {
log.Printf("❌ Error forwarding to Anthropic API: %v", err) log.Printf("❌ Error forwarding to Anthropic API: %v", err)
writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError) writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError)
@ -354,6 +271,10 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
var fullResponseText strings.Builder var fullResponseText strings.Builder
var toolCalls []model.ContentBlock var toolCalls []model.ContentBlock
var streamingChunks []string var streamingChunks []string
var finalUsage *model.AnthropicUsage
var messageID string
var modelName string
var stopReason string
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() { for scanner.Scan() {
@ -369,12 +290,74 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
} }
jsonData := strings.TrimPrefix(line, "data: ") jsonData := strings.TrimPrefix(line, "data: ")
var event model.StreamingEvent
if err := json.Unmarshal([]byte(jsonData), &event); err != nil { // Parse as generic JSON first to capture usage data
var genericEvent map[string]interface{}
if err := json.Unmarshal([]byte(jsonData), &genericEvent); err != nil {
log.Printf("⚠️ Error unmarshalling streaming event: %v", err) log.Printf("⚠️ Error unmarshalling streaming event: %v", err)
continue continue
} }
// Capture usage data and metadata from message_start event
if eventType, ok := genericEvent["type"].(string); ok && eventType == "message_start" {
if message, ok := genericEvent["message"].(map[string]interface{}); ok {
// Capture message metadata
if id, ok := message["id"].(string); ok {
messageID = id
}
if model, ok := message["model"].(string); ok {
modelName = model
}
if reason, ok := message["stop_reason"].(string); ok {
stopReason = reason
}
// Capture initial usage data from message_start
if usage, ok := message["usage"].(map[string]interface{}); ok {
finalUsage = &model.AnthropicUsage{}
if inputTokens, ok := usage["input_tokens"].(float64); ok {
finalUsage.InputTokens = int(inputTokens)
}
if outputTokens, ok := usage["output_tokens"].(float64); ok {
finalUsage.OutputTokens = int(outputTokens)
}
if cacheCreation, ok := usage["cache_creation_input_tokens"].(float64); ok {
finalUsage.CacheCreationInputTokens = int(cacheCreation)
}
if cacheRead, ok := usage["cache_read_input_tokens"].(float64); ok {
finalUsage.CacheReadInputTokens = int(cacheRead)
}
if tier, ok := usage["service_tier"].(string); ok {
finalUsage.ServiceTier = tier
}
log.Printf("📊 Captured initial usage from message_start: %+v", finalUsage)
} else {
log.Printf("⚠️ No usage data found in message_start event")
}
}
}
// Update output tokens from message_delta event
if eventType, ok := genericEvent["type"].(string); ok && eventType == "message_delta" {
// Usage is at top level for message_delta events
if usage, ok := genericEvent["usage"].(map[string]interface{}); ok {
if finalUsage != nil {
if outputTokens, ok := usage["output_tokens"].(float64); ok {
finalUsage.OutputTokens = int(outputTokens)
log.Printf("📊 Updated output tokens from message_delta: %d", int(outputTokens))
}
} else {
log.Printf("⚠️ finalUsage is nil when trying to update from message_delta usage")
}
}
}
// Parse as structured event for content processing
var event model.StreamingEvent
if err := json.Unmarshal([]byte(jsonData), &event); err != nil {
continue // Skip if structured parsing fails, but we already got the usage data above
}
switch event.Type { switch event.Type {
case "content_block_delta": case "content_block_delta":
if event.Delta != nil { if event.Delta != nil {
@ -391,8 +374,7 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
toolCalls = append(toolCalls, *event.ContentBlock) toolCalls = append(toolCalls, *event.ContentBlock)
} }
case "message_stop": case "message_stop":
// End of stream // End of stream - scanner will exit on its own
break
} }
} }
@ -405,19 +387,41 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
CompletedAt: time.Now().Format(time.RFC3339), CompletedAt: time.Now().Format(time.RFC3339),
} }
// Create a structured body for the log // Create a structured response body that matches Anthropic's format
var responseBody model.AnthropicMessage var contentBlocks []model.AnthropicContentBlock
responseBody.Role = "assistant"
var contentBlocks []model.ContentBlock
if fullResponseText.Len() > 0 { if fullResponseText.Len() > 0 {
contentBlocks = append(contentBlocks, model.ContentBlock{ contentBlocks = append(contentBlocks, model.AnthropicContentBlock{
Type: "text", Type: "text",
Text: fullResponseText.String(), Text: fullResponseText.String(),
}) })
} }
contentBlocks = append(contentBlocks, toolCalls...)
responseBody.Content = contentBlocks // Create an AnthropicResponse-like structure for consistency
responseLog.Body = responseBody responseBody := map[string]interface{}{
"content": contentBlocks,
"id": messageID,
"model": modelName,
"role": "assistant",
"stop_reason": stopReason,
"type": "message",
}
// Add usage data if we captured it
if finalUsage != nil {
responseBody["usage"] = finalUsage
log.Printf("📊 Final usage data being stored: %+v", finalUsage)
} else {
log.Printf("⚠️ No usage data captured for streaming response - finalUsage is nil")
}
// Marshal to JSON for storage
responseBodyBytes, err := json.Marshal(responseBody)
if err != nil {
log.Printf("❌ Error marshaling streaming response body: %v", err)
responseBodyBytes = []byte("{}")
}
responseLog.Body = json.RawMessage(responseBodyBytes)
requestLog.Response = responseLog requestLog.Response = responseLog
if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil { if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil {
@ -432,6 +436,10 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
} }
func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) { func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) {
// Log response headers for debugging
log.Printf("📋 Response headers: Content-Encoding=%s, Content-Type=%s, Status=%d",
resp.Header.Get("Content-Encoding"), resp.Header.Get("Content-Type"), resp.StatusCode)
responseBytes, err := io.ReadAll(resp.Body) responseBytes, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Printf("❌ Error reading Anthropic response: %v", err) log.Printf("❌ Error reading Anthropic response: %v", err)
@ -439,21 +447,35 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
return return
} }
// Log first few bytes to help debug compression issues
if len(responseBytes) > 0 {
log.Printf("📊 Response body starts with: %x (first 10 bytes)", responseBytes[:min(10, len(responseBytes))])
}
responseLog := &model.ResponseLog{ responseLog := &model.ResponseLog{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Headers: SanitizeHeaders(resp.Header), Headers: SanitizeHeaders(resp.Header),
BodyText: string(responseBytes),
ResponseTime: time.Since(startTime).Milliseconds(), ResponseTime: time.Since(startTime).Milliseconds(),
IsStreaming: false, IsStreaming: false,
CompletedAt: time.Now().Format(time.RFC3339), CompletedAt: time.Now().Format(time.RFC3339),
} }
// Try to parse as JSON for structured logging // Parse the response as AnthropicResponse for consistent structure
if resp.Header.Get("Content-Type") == "application/json" { if resp.StatusCode == http.StatusOK {
var jsonBody interface{} var anthropicResp model.AnthropicResponse
if json.Unmarshal(responseBytes, &jsonBody) == nil { if err := json.Unmarshal(responseBytes, &anthropicResp); err == nil {
responseLog.Body = jsonBody // Successfully parsed - store the structured response
responseLog.Body = json.RawMessage(responseBytes)
log.Printf("✅ Successfully parsed Anthropic response")
} else {
// If parsing fails, store as text but log the error
log.Printf("⚠️ Failed to parse Anthropic response: %v", err)
log.Printf("📄 Response body (first 500 chars): %s", string(responseBytes[:min(500, len(responseBytes))]))
responseLog.BodyText = string(responseBytes)
} }
} else {
// For error responses, store as text
responseLog.BodyText = string(responseBytes)
} }
requestLog.Response = responseLog requestLog.Response = responseLog
@ -474,6 +496,14 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
w.Write(responseBytes) w.Write(responseBytes)
} }
// Helper function to get minimum of two integers
func min(a, b int) int {
if a < b {
return a
}
return b
}
func generateRequestID() string { func generateRequestID() string {
bytes := make([]byte, 8) bytes := make([]byte, 8)
rand.Read(bytes) rand.Read(bytes)

View file

@ -41,7 +41,7 @@ type RequestLog struct {
type ResponseLog struct { type ResponseLog struct {
StatusCode int `json:"statusCode"` StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"` Headers map[string][]string `json:"headers"`
Body interface{} `json:"body,omitempty"` Body json.RawMessage `json:"body,omitempty"`
BodyText string `json:"bodyText,omitempty"` BodyText string `json:"bodyText,omitempty"`
ResponseTime int64 `json:"responseTime"` ResponseTime int64 `json:"responseTime"`
StreamingChunks []string `json:"streamingChunks,omitempty"` StreamingChunks []string `json:"streamingChunks,omitempty"`
@ -60,25 +60,23 @@ type ChatCompletionRequest struct {
Stream bool `json:"stream,omitempty"` Stream bool `json:"stream,omitempty"`
} }
type ChatCompletionResponse struct { type AnthropicUsage struct {
ID string `json:"id"` InputTokens int `json:"input_tokens"`
Object string `json:"object"` OutputTokens int `json:"output_tokens"`
Created int64 `json:"created"` CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
Model string `json:"model"` CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
Choices []Choice `json:"choices"` ServiceTier string `json:"service_tier,omitempty"`
Usage Usage `json:"usage"`
} }
type Choice struct { type AnthropicResponse struct {
Index int `json:"index"` Content []AnthropicContentBlock `json:"content"`
Message ChatMessage `json:"message"` ID string `json:"id"`
FinishReason string `json:"finish_reason"` Model string `json:"model"`
} Role string `json:"role"`
StopReason string `json:"stop_reason"`
type Usage struct { StopSequence *string `json:"stop_sequence"`
PromptTokens int `json:"prompt_tokens"` Type string `json:"type"`
CompletionTokens int `json:"completion_tokens"` Usage AnthropicUsage `json:"usage"`
TotalTokens int `json:"total_tokens"`
} }
type AnthropicContentBlock struct { type AnthropicContentBlock struct {
@ -179,7 +177,6 @@ type ErrorResponse struct {
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
} }
type StreamingEvent struct { type StreamingEvent struct {
Type string `json:"type"` Type string `json:"type"`
Index *int `json:"index,omitempty"` Index *int `json:"index,omitempty"`

View file

@ -1,9 +1,8 @@
package service package service
import ( import (
"bytes" "compress/gzip"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -13,12 +12,10 @@ import (
"time" "time"
"github.com/seifghazi/claude-code-monitor/internal/config" "github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
) )
type AnthropicService interface { type AnthropicService interface {
ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error)
GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error)
} }
type anthropicService struct { type anthropicService struct {
@ -29,263 +26,97 @@ type anthropicService struct {
func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService { func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService {
return &anthropicService{ return &anthropicService{
client: &http.Client{ client: &http.Client{
Timeout: 60 * time.Second, Timeout: 300 * time.Second, // Increased timeout to 5 minutes
}, },
config: cfg, config: cfg,
} }
} }
func (s *anthropicService) ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error) { func (s *anthropicService) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) {
if apiKey == "" { // Clone the request to avoid modifying the original
return nil, fmt.Errorf("API key not provided") proxyReq := originalReq.Clone(ctx)
}
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
if s.config.BaseURL == "" {
return nil, fmt.Errorf("anthropic base URL is not configured. Please set ANTHROPIC_BASE_URL")
}
// Parse the configured base URL
baseURL, err := url.Parse(s.config.BaseURL) baseURL, err := url.Parse(s.config.BaseURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse anthropic base URL '%s': %w", s.config.BaseURL, err) return nil, fmt.Errorf("failed to parse base URL '%s': %w", s.config.BaseURL, err)
} }
if baseURL.Scheme == "" || baseURL.Host == "" { if baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("invalid anthropic base URL, scheme and host are required: %s", s.config.BaseURL) return nil, fmt.Errorf("invalid base URL, scheme and host are required: %s", s.config.BaseURL)
} }
baseURL.Path = path.Join(baseURL.Path, "/v1/messages") // Update the destination URL
fullURL := baseURL.String() proxyReq.URL.Scheme = baseURL.Scheme
proxyReq.URL.Host = baseURL.Host
proxyReq.URL.Path = path.Join(baseURL.Path, "/v1/messages")
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(requestBody)) // Preserve query parameters from original request
if err != nil { proxyReq.URL.RawQuery = originalReq.URL.RawQuery
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json") // Clear fields that can't be set in client requests
req.Header.Set("x-api-key", apiKey) proxyReq.RequestURI = "" // This is set by the server and must be cleared
req.Header.Set("anthropic-version", s.config.Version) proxyReq.Host = "" // Let Go set this from the URL
resp, err := s.client.Do(req) // Forward the request with all original headers intact
resp, err := s.client.Do(proxyReq)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err) return nil, fmt.Errorf("failed to send request: %w", err)
} }
// Handle gzip decompression
if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") {
decompressedResp, err := s.decompressGzipResponse(resp)
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to decompress gzip response: %w", err)
}
return decompressedResp, nil
}
return resp, nil return resp, nil
} }
func (s *anthropicService) GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error) { func (s *anthropicService) decompressGzipResponse(resp *http.Response) (*http.Response, error) {
if apiKey == "" { // Create a gzip reader
return nil, fmt.Errorf("API key not provided") gzipReader, err := gzip.NewReader(resp.Body)
}
userContentParts := s.extractUserContent(messages)
if len(userContentParts) == 0 {
return nil, fmt.Errorf("no user content found to grade")
}
originalPrompt := strings.Join(userContentParts, "\n\n")
systemPrompt := s.extractSystemPrompt(systemMessages)
gradingPrompt := s.buildGradingPrompt(originalPrompt, systemPrompt)
claudeRequest := &model.AnthropicRequest{
Model: "claude-3-5-sonnet-20240620",
MaxTokens: 4000,
Messages: []model.AnthropicMessage{
{
Role: "user",
Content: gradingPrompt,
},
},
}
resp, err := s.ForwardRequest(ctx, claudeRequest, apiKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send grading request: %w", err) return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
} }
var claudeResponse struct { // Read the decompressed data
Content []struct { decompressedData, err := io.ReadAll(gzipReader)
Type string `json:"type"` if err != nil {
Text string `json:"text"` gzipReader.Close()
} `json:"content"` return nil, fmt.Errorf("failed to read decompressed data: %w", err)
} }
if err := json.NewDecoder(resp.Body).Decode(&claudeResponse); err != nil { // Close the gzip reader and original body
return nil, fmt.Errorf("failed to decode response: %w", err) gzipReader.Close()
resp.Body.Close()
// Create a new response with decompressed body
newResp := &http.Response{
Status: resp.Status,
StatusCode: resp.StatusCode,
Proto: resp.Proto,
ProtoMajor: resp.ProtoMajor,
ProtoMinor: resp.ProtoMinor,
Header: resp.Header.Clone(),
ContentLength: int64(len(decompressedData)),
TransferEncoding: resp.TransferEncoding,
Close: resp.Close,
Uncompressed: true,
Trailer: resp.Trailer,
Request: resp.Request,
TLS: resp.TLS,
} }
if len(claudeResponse.Content) == 0 { // Remove Content-Encoding header since we've decompressed
return nil, fmt.Errorf("empty response from Claude") newResp.Header.Del("Content-Encoding")
}
return s.parseGradingResponse(claudeResponse.Content[0].Text) // Set the decompressed body
} newResp.Body = io.NopCloser(strings.NewReader(string(decompressedData)))
func (s *anthropicService) extractUserContent(messages []model.AnthropicMessage) []string { return newResp, nil
var userContentParts []string
for _, msg := range messages {
if msg.Role == "user" {
blocks := msg.GetContentBlocks()
for _, block := range blocks {
if block.Type == "text" {
text := strings.TrimSpace(block.Text)
if text != "" && !s.isSystemReminder(text) {
userContentParts = append(userContentParts, text)
}
}
}
}
}
return userContentParts
}
func (s *anthropicService) extractSystemPrompt(systemMessages []model.AnthropicSystemMessage) string {
var systemPromptParts []string
for _, msg := range systemMessages {
if msg.Text != "" {
systemPromptParts = append(systemPromptParts, msg.Text)
}
}
systemPrompt := strings.Join(systemPromptParts, "\n\n")
if systemPrompt == "" {
systemPrompt = "No system prompt was provided for this request."
}
return systemPrompt
}
func (s *anthropicService) isSystemReminder(text string) bool {
text = strings.TrimSpace(text)
lowerText := strings.ToLower(text)
systemPatterns := []string{
"<system-reminder>",
"system-reminder>",
"this is a reminder that your todo list",
"as you answer the user's questions, you can use the following context:",
"important-instruction-reminders",
"do not mention this to the user explicitly",
"the user opened the file",
"the user selected the following lines",
"caveat: the messages below were generated by the user while running local commands",
}
for _, pattern := range systemPatterns {
if strings.Contains(lowerText, strings.ToLower(pattern)) {
return true
}
}
return false
}
func (s *anthropicService) buildGradingPrompt(originalPrompt, systemPrompt string) string {
return fmt.Sprintf(`<task>
You are an expert prompt engineer specializing in Anthropic's Claude best practices. Please analyze the following user prompt and provide a comprehensive grading report.
<original_prompt>
%s
</original_prompt>
For context, here is the system prompt used in this request:
<system_prompt>
%s
</system_prompt>
Please evaluate this prompt across these 5 criteria and provide your analysis in the exact JSON format specified below:
1. **Clarity & Explicitness** (1-5): How clear and specific are the instructions?
2. **Context & Motivation** (1-5): Does it explain why the task matters and provide sufficient background?
3. **Structure & Format** (1-5): Is it well-organized? Does it use XML tags effectively?
4. **Examples & Details** (1-5): Are there sufficient examples and detailed specifications?
5. **Task-Specific Best Practices** (1-5): Does it follow Claude-specific best practices (thinking prompts, role specification, etc.)?
Additionally, create an improved version of this prompt that addresses any weaknesses you identify. Include XML tags to structure the output if necessary.
</task>
<response_format>
Please respond with a JSON object in exactly this format:
{
"overallScore": [1-5 integer],
"detailedFeedback": "[comprehensive analysis of the prompt's strengths and weaknesses]",
"improvedPrompt": "[your rewritten version of the prompt that addresses the issues]",
"criteria": {
"clarity": {
"score": [1-5 integer],
"feedback": "[specific feedback for clarity]"
},
"context": {
"score": [1-5 integer],
"feedback": "[specific feedback for context]"
},
"structure": {
"score": [1-5 integer],
"feedback": "[specific feedback for structure]"
},
"examples": {
"score": [1-5 integer],
"feedback": "[specific feedback for examples]"
},
"taskSpecific": {
"score": [1-5 integer],
"feedback": "[specific feedback for task-specific practices]"
}
}
}
</response_format>`, originalPrompt, systemPrompt)
}
func (s *anthropicService) parseGradingResponse(responseText string) (*model.PromptGrade, error) {
var jsonStr string
if strings.Contains(responseText, "```json") {
start := strings.Index(responseText, "```json") + 7
end := strings.Index(responseText[start:], "```")
if end != -1 {
jsonStr = strings.TrimSpace(responseText[start : start+end])
}
} else {
jsonStart := strings.Index(responseText, "{")
jsonEnd := strings.LastIndex(responseText, "}")
if jsonStart == -1 || jsonEnd == -1 {
return nil, fmt.Errorf("no JSON found in Claude's response")
}
jsonStr = responseText[jsonStart : jsonEnd+1]
}
if jsonStr == "" {
return nil, fmt.Errorf("no JSON found in Claude's response")
}
var gradingResult struct {
OverallScore int `json:"overallScore"`
DetailedFeedback string `json:"detailedFeedback"`
ImprovedPrompt string `json:"improvedPrompt"`
Criteria map[string]model.CriteriaScore `json:"criteria"`
}
if err := json.Unmarshal([]byte(jsonStr), &gradingResult); err != nil {
return nil, fmt.Errorf("failed to parse grading result: %w", err)
}
return &model.PromptGrade{
Score: gradingResult.OverallScore,
MaxScore: 5,
Feedback: gradingResult.DetailedFeedback,
ImprovedPrompt: gradingResult.ImprovedPrompt,
Criteria: gradingResult.Criteria,
GradingTimestamp: time.Now().Format(time.RFC3339),
IsProcessing: false,
}, nil
} }

View file

@ -73,15 +73,6 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
return "", fmt.Errorf("failed to marshal body: %w", err) return "", fmt.Errorf("failed to marshal body: %w", err)
} }
// Extract model from body if available
var modelName string
if body, ok := request.Body.(map[string]interface{}); ok {
if model, ok := body["model"].(string); ok {
modelName = model
request.Model = model // Also set it in the struct
}
}
query := ` query := `
INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model) INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -96,7 +87,7 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
string(bodyJSON), string(bodyJSON),
request.UserAgent, request.UserAgent,
request.ContentType, request.ContentType,
modelName, request.Model,
) )
if err != nil { if err != nil {

View file

@ -68,7 +68,16 @@ interface Request {
response?: { response?: {
statusCode: number; statusCode: number;
headers: Record<string, string[]>; headers: Record<string, string[]>;
body?: any; body?: {
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
service_tier?: string;
};
[key: string]: any;
};
bodyText?: string; bodyText?: string;
responseTime: number; responseTime: number;
streamingChunks?: string[]; streamingChunks?: string[];
@ -222,17 +231,13 @@ export default function Index() {
} }
}; };
const loadConversations = async (filter?: string, loadMore = false) => { const loadConversations = async (loadMore = false) => {
setIsFetching(true); setIsFetching(true);
const pageToFetch = loadMore ? conversationsCurrentPage + 1 : 1; const pageToFetch = loadMore ? conversationsCurrentPage + 1 : 1;
try { try {
const currentModelFilter = filter || modelFilter;
const url = new URL('/api/conversations', window.location.origin); const url = new URL('/api/conversations', window.location.origin);
url.searchParams.append("page", pageToFetch.toString()); url.searchParams.append("page", pageToFetch.toString());
url.searchParams.append("limit", itemsPerPage.toString()); url.searchParams.append("limit", itemsPerPage.toString());
if (currentModelFilter !== "all") {
url.searchParams.append("model", currentModelFilter);
}
const response = await fetch(url.toString()); const response = await fetch(url.toString());
if (!response.ok) { if (!response.ok) {
@ -322,39 +327,39 @@ export default function Index() {
}; };
const getRequestSummary = (request: Request) => { const getRequestSummary = (request: Request) => {
if (request.body?.messages) { const parts = [];
const messageCount = request.body.messages.length;
// Add token usage if available
if (request.response?.body?.usage) {
const usage = request.response.body.usage;
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const totalTokens = inputTokens + outputTokens;
// Count tool calls if (totalTokens > 0) {
const toolCalls = request.body.messages.reduce((count, msg) => { parts.push(`🪙 ${totalTokens.toLocaleString()} tokens`);
if (msg.content && Array.isArray(msg.content)) {
return count + msg.content.filter((c: any) => c.type === 'tool_use').length; if (usage.cache_read_input_tokens) {
parts.push(`💾 ${usage.cache_read_input_tokens.toLocaleString()} cached`);
} }
return count;
}, 0);
// Count tool definitions in system prompt
let toolDefinitions = 0;
if (request.body.system) {
request.body.system.forEach(sys => {
if (sys.text && sys.text.includes('<functions>')) {
const functionMatches = [...sys.text.matchAll(/<function>([\s\S]*?)<\/function>/g)];
toolDefinitions += functionMatches.length;
}
});
} }
let summary = `💬 ${messageCount} messages`;
if (toolDefinitions > 0) {
summary += ` • 🛠️ ${toolDefinitions} tools available`;
}
if (toolCalls > 0) {
summary += ` • ⚡ ${toolCalls} tool calls executed`;
}
return summary;
} }
return '📡 API request';
// Add response time if available
if (request.response?.responseTime) {
const seconds = (request.response.responseTime / 1000).toFixed(1);
parts.push(`⏱️ ${seconds}s`);
}
// Add model if available
if (request.body?.model) {
const modelShort = request.body.model.includes('opus') ? 'Opus' :
request.body.model.includes('sonnet') ? 'Sonnet' :
request.body.model.includes('haiku') ? 'Haiku' : 'Model';
parts.push(`🤖 ${modelShort}`);
}
return parts.length > 0 ? parts.join(' • ') : '📡 API request';
}; };
const showRequestDetails = (requestId: number) => { const showRequestDetails = (requestId: number) => {
@ -495,7 +500,7 @@ export default function Index() {
if (viewMode === 'requests') { if (viewMode === 'requests') {
loadRequests(modelFilter); loadRequests(modelFilter);
} else { } else {
loadConversations(modelFilter); loadConversations();
} }
}, [viewMode, modelFilter]); }, [viewMode, modelFilter]);
@ -504,139 +509,122 @@ export default function Index() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-200"> <header className="sticky top-0 z-40 bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4"> <div className="max-w-7xl mx-auto px-6 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center">
<Activity className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">Claude Code Monitor</h1>
</div>
</div>
</div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-2"> <h1 className="text-lg font-semibold text-gray-900">Claude Code Monitor</h1>
<button </div>
onClick={() => loadRequests()} <div className="flex items-center space-x-2">
className="p-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors" <button
title="Refresh" onClick={() => loadRequests()}
> className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors"
<RefreshCw className="w-5 h-5" /> title="Refresh"
</button> >
<button <RefreshCw className="w-4 h-4" />
onClick={clearRequests} </button>
className="p-2 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition-colors" <button
title="Clear all requests" onClick={clearRequests}
> className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
<Trash2 className="w-5 h-5" /> title="Clear all requests"
</button> >
</div> <Trash2 className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/* Filter buttons */}
<div className="mb-6 flex justify-center">
<div className="inline-flex items-center bg-gray-100/80 rounded-lg p-1 space-x-1">
<button
onClick={() => handleModelFilterChange("all")}
className={`px-4 py-2 rounded-md text-sm font-semibold transition-all duration-200 ${
modelFilter === "all"
? "bg-white text-blue-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
All Models
</button>
<button
onClick={() => handleModelFilterChange("opus")}
className={`px-4 py-2 rounded-md text-sm font-semibold transition-all duration-200 flex items-center space-x-2 ${
modelFilter === "opus"
? "bg-white text-purple-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Brain className="w-4 h-4" />
<span>Opus</span>
</button>
<button
onClick={() => handleModelFilterChange("sonnet")}
className={`px-4 py-2 rounded-md text-sm font-semibold transition-all duration-200 flex items-center space-x-2 ${
modelFilter === "sonnet"
? "bg-white text-indigo-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Sparkles className="w-4 h-4" />
<span>Sonnet</span>
</button>
<button
onClick={() => handleModelFilterChange("haiku")}
className={`px-4 py-2 rounded-md text-sm font-semibold transition-all duration-200 flex items-center space-x-2 ${
modelFilter === "haiku"
? "bg-white text-teal-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Zap className="w-4 h-4" />
<span>Haiku</span>
</button>
</div>
</div>
{/* View mode toggle */} {/* View mode toggle */}
<div className="mb-6 flex justify-center"> <div className="mb-4 flex justify-center">
<div className="p-1 bg-gray-200 rounded-full flex items-center"> <div className="inline-flex items-center bg-gray-100 rounded p-0.5">
<button <button
onClick={() => setViewMode("requests")} onClick={() => setViewMode("requests")}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "requests" viewMode === "requests"
? "bg-white text-gray-900 shadow-sm" ? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900" : "text-gray-600 hover:text-gray-900"
}`} }`}
> >
<List className="w-4 h-4 inline mr-1" />
Requests Requests
</button> </button>
<button <button
onClick={() => setViewMode("conversations")} onClick={() => setViewMode("conversations")}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${ className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "conversations" viewMode === "conversations"
? "bg-white text-gray-900 shadow-sm" ? "bg-white text-gray-900 shadow-sm"
: "text-gray-600 hover:text-gray-900" : "text-gray-600 hover:text-gray-900"
}`} }`}
> >
<MessageCircle className="w-4 h-4 inline mr-1" />
Conversations Conversations
</button> </button>
</div> </div>
</div> </div>
{/* Filter buttons - only show for requests view */}
{viewMode === "requests" && (
<div className="mb-6 flex justify-center">
<div className="inline-flex items-center bg-gray-100 rounded p-0.5 space-x-0.5">
<button
onClick={() => handleModelFilterChange("all")}
className={`px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 ${
modelFilter === "all"
? "bg-white text-gray-900 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
All Models
</button>
<button
onClick={() => handleModelFilterChange("opus")}
className={`px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 ${
modelFilter === "opus"
? "bg-white text-purple-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Brain className="w-3 h-3" />
<span>Opus</span>
</button>
<button
onClick={() => handleModelFilterChange("sonnet")}
className={`px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 ${
modelFilter === "sonnet"
? "bg-white text-indigo-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Sparkles className="w-3 h-3" />
<span>Sonnet</span>
</button>
<button
onClick={() => handleModelFilterChange("haiku")}
className={`px-3 py-1.5 rounded text-xs font-medium transition-all duration-200 flex items-center space-x-1 ${
modelFilter === "haiku"
? "bg-white text-teal-600 shadow-sm"
: "bg-transparent text-gray-600 hover:text-gray-900"
}`}
>
<Zap className="w-3 h-3" />
<span>Haiku</span>
</button>
</div>
</div>
)}
{/* Main Content */} {/* Main Content */}
<main className="max-w-7xl mx-auto px-6 py-8 space-y-8"> <main className="max-w-7xl mx-auto px-6 py-8 space-y-8">
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-6"> <div className="mb-6">
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <div>
<p className="text-sm font-medium text-gray-500"> <p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
{viewMode === "requests" ? "Total Requests" : "Total Conversations"} {viewMode === "requests" ? "Total Requests" : "Total Conversations"}
</p> </p>
<p className="text-2xl font-semibold text-gray-900"> <p className="text-2xl font-semibold text-gray-900 mt-1">
{viewMode === "requests" ? requests.length : conversations.length} {viewMode === "requests" ? requests.length : conversations.length}
</p> </p>
{/* <p className="text-xs text-gray-500">All time</p> */}
</div>
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center">
{viewMode === "requests" ? (
<Activity className="w-6 h-6 text-blue-600" />
) : (
<MessageCircle className="w-6 h-6 text-blue-600" />
)}
</div> </div>
</div> </div>
</div> </div>
@ -645,108 +633,101 @@ export default function Index() {
{/* Main Content */} {/* Main Content */}
{viewMode === "requests" ? ( {viewMode === "requests" ? (
/* Request History */ /* Request History */
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200"> <div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-3"> <h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Request History</h2>
<List className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Request History</h2>
</div>
{/* <div className="flex items-center space-x-3">
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-white border border-gray-300 rounded-lg px-3 py-2 text-sm text-gray-900 focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
>
<option value="all">All Requests</option>
<option value="messages">Messages</option>
<option value="completions">Completions</option>
<option value="models">Models</option>
</select>
</div> */}
</div> </div>
</div> </div>
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{(isFetching && requestsCurrentPage === 1) || isPending ? ( {(isFetching && requestsCurrentPage === 1) || isPending ? (
<div className="p-12 text-center"> <div className="p-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-gray-400" /> <Loader2 className="w-6 h-6 mx-auto animate-spin text-gray-400" />
<p className="mt-4 text-sm text-gray-500">Loading requests...</p> <p className="mt-2 text-xs text-gray-500">Loading requests...</p>
</div> </div>
) : filteredRequests.length === 0 ? ( ) : filteredRequests.length === 0 ? (
<div className="p-12 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
<div className="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-6"> <h3 className="text-sm font-medium text-gray-600 mb-1">No requests found</h3>
<Inbox className="w-10 h-10 text-gray-400" /> <p className="text-xs text-gray-500">Make sure you have set <code className="font-mono bg-gray-100 px-1 py-0.5 rounded">ANTHROPIC_BASE_URL</code> to point at the proxy</p>
</div>
<h3 className="text-lg font-medium text-gray-600 mb-2">No requests found</h3>
<p className="text-sm text-gray-500">Make sure you have set the <code>ANTHROPIC_BASE_URL</code> environment variable to the proxy server URL</p>
</div> </div>
) : ( ) : (
<> <>
{filteredRequests.map(request => ( {filteredRequests.map(request => (
<div key={request.id} className="p-6 hover:bg-gray-50 transition-colors cursor-pointer" onClick={() => showRequestDetails(request.id)}> <div key={request.id} className="px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" onClick={() => showRequestDetails(request.id)}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-4 flex-1"> <div className="flex-1 min-w-0 mr-4">
<span className={`method-badge ${getMethodColor(request.method)} px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide`}> {/* Model and Status */}
{request.method} <div className="flex items-center space-x-3 mb-1">
</span> <h3 className="text-sm font-medium">
<div className="flex flex-col"> {request.body?.model ? (
<div className="flex items-center space-x-2"> request.body.model.includes('opus') ? <span className="text-purple-600 font-semibold">Opus</span> :
<span className="text-gray-900 font-semibold text-base">{request.endpoint}</span> request.body.model.includes('sonnet') ? <span className="text-indigo-600 font-semibold">Sonnet</span> :
{request.conversationId && ( request.body.model.includes('haiku') ? <span className="text-teal-600 font-semibold">Haiku</span> :
<span className="text-xs bg-purple-50 border border-purple-200 text-purple-700 px-2 py-1 rounded-full"> <span className="text-gray-900">{request.body.model.split('-')[0]}</span>
Turn {request.turnNumber} ) : <span className="text-gray-900">API</span>}
</h3>
{request.response?.statusCode && (
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
request.response.statusCode >= 200 && request.response.statusCode < 300
? 'bg-green-100 text-green-700'
: request.response.statusCode >= 300 && request.response.statusCode < 400
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{request.response.statusCode}
</span>
)}
{request.conversationId && (
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded font-medium">
Turn {request.turnNumber}
</span>
)}
</div>
{/* Endpoint */}
<div className="text-xs text-gray-600 font-mono mb-1">
{request.endpoint}
</div>
{/* Metrics Row */}
<div className="flex items-center space-x-3 text-xs">
{request.response?.body?.usage && (
<>
<span className="font-mono text-gray-600">
<span className="font-medium text-gray-900">{((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()}</span> tokens
</span> </span>
)} {request.response.body.usage.cache_read_input_tokens && (
</div> <span className="font-mono bg-green-50 text-green-700 px-1.5 py-0.5 rounded">
<span className="text-gray-500 text-sm">{new Date(request.timestamp).toLocaleString()}</span> {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached
</span>
)}
</>
)}
{request.response?.responseTime && (
<span className="font-mono text-gray-600">
<span className="font-medium text-gray-900">{(request.response.responseTime / 1000).toFixed(2)}</span>s
</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex-shrink-0 text-right">
{request.body?.model && ( <div className="text-xs text-gray-500">
<span className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded-lg font-medium"> {new Date(request.timestamp).toLocaleDateString()}
{request.body.model} </div>
</span> <div className="text-xs text-gray-400">
)} {new Date(request.timestamp).toLocaleTimeString()}
{/* {request.promptGrade ? (
<span className={`text-xs px-2 py-1 rounded-lg font-medium border ${
request.promptGrade.score >= 4
? 'bg-green-50 border-green-200 text-green-700'
: request.promptGrade.score >= 3
? 'bg-yellow-50 border-yellow-200 text-yellow-700'
: 'bg-red-50 border-red-200 text-red-700'
}`}>
{request.promptGrade.score >= 4 ? '🎉' : request.promptGrade.score >= 3 ? '👍' : '⚠️'} {request.promptGrade.score}/5
</span>
) : (
canGradeRequest(request) && (
<button
onClick={(e) => {
e.stopPropagation();
gradeRequest(request.id);
}}
className="text-xs bg-purple-50 border border-purple-200 text-purple-700 px-3 py-1.5 rounded-lg font-medium hover:bg-purple-100 transition-colors flex items-center space-x-1"
>
<Target className="w-3 h-3" />
<span>Grade Prompt</span>
</button>
)
)} */}
<div className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center">
<ChevronRight className="w-4 h-4 text-gray-400" />
</div> </div>
</div> </div>
</div> </div>
<div className="text-gray-600 text-sm bg-gray-50 rounded-lg p-3 border border-gray-200">
{getRequestSummary(request)}
</div>
</div> </div>
))} ))}
{hasMoreRequests && ( {hasMoreRequests && (
<div className="p-4 text-center"> <div className="p-3 text-center border-t border-gray-100">
<button <button
onClick={() => loadRequests(modelFilter, true)} onClick={() => loadRequests(modelFilter, true)}
disabled={isFetching} disabled={isFetching}
className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:bg-blue-300 transition-colors" className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 disabled:opacity-50 transition-colors"
> >
{isFetching ? "Loading..." : "Load More"} {isFetching ? "Loading..." : "Load More"}
</button> </button>
@ -758,73 +739,77 @@ export default function Index() {
</div> </div>
) : ( ) : (
/* Conversations View */ /* Conversations View */
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm"> <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200"> <div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<div className="flex items-center justify-between"> <h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Conversations</h2>
<div className="flex items-center space-x-3">
<MessageCircle className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Conversations</h2>
</div>
</div>
</div> </div>
<div className="divide-y divide-gray-200"> <div className="divide-y divide-gray-200">
{(isFetching && conversationsCurrentPage === 1) || isPending ? ( {(isFetching && conversationsCurrentPage === 1) || isPending ? (
<div className="p-12 text-center"> <div className="p-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-gray-400" /> <Loader2 className="w-6 h-6 mx-auto animate-spin text-gray-400" />
<p className="mt-4 text-sm text-gray-500">Loading conversations...</p> <p className="mt-2 text-xs text-gray-500">Loading conversations...</p>
</div> </div>
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
<div className="p-12 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
<div className="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mx-auto mb-6"> <h3 className="text-sm font-medium text-gray-600 mb-1">No conversations found</h3>
<MessageCircle className="w-10 h-10 text-gray-400" /> <p className="text-xs text-gray-500">Start a conversation to see it appear here</p>
</div>
<h3 className="text-lg font-medium text-gray-600 mb-2">No conversations found</h3>
<p className="text-sm text-gray-500">Start a conversation to see it appear here</p>
</div> </div>
) : ( ) : (
<> <>
{conversations.map(conversation => ( {conversations.map(conversation => (
<div key={conversation.id} className="p-6 hover:bg-gray-50 transition-colors cursor-pointer" onClick={() => loadConversationDetails(conversation.id, conversation.projectName)}> <div key={conversation.id} className="px-4 py-4 hover:bg-gray-50 transition-colors cursor-pointer border-b border-gray-100 last:border-b-0" onClick={() => loadConversationDetails(conversation.id, conversation.projectName)}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-4 flex-1"> <div className="flex-1 min-w-0 mr-4">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center"> <div className="flex items-center space-x-2 mb-2">
<MessageCircle className="w-6 h-6 text-white" /> <span className="text-sm font-semibold text-gray-900 font-mono">
</div> #{conversation.id.slice(-8)}
<div className="flex flex-col"> </span>
<span className="text-gray-900 font-semibold text-base">Conversation {conversation.id.slice(-8)}</span> <span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full font-medium">
<span className="text-gray-500 text-sm">{new Date(conversation.startTime).toLocaleString()}</span> {conversation.requestCount} turns
</span>
<span className="text-xs px-2 py-0.5 bg-gray-100 text-gray-700 rounded-full">
{formatDuration(conversation.duration)}
</span>
{conversation.projectName && ( {conversation.projectName && (
<span className="text-xs text-purple-600 font-medium">{conversation.projectName}</span> <span className="text-xs px-2 py-0.5 bg-purple-100 text-purple-700 rounded-full font-medium">
{conversation.projectName}
</span>
)}
</div>
<div className="space-y-2">
<div className="bg-gray-50 rounded p-2 border border-gray-200">
<div className="text-xs font-medium text-gray-600 mb-0.5">First Message</div>
<div className="text-xs text-gray-700 line-clamp-2">
{conversation.firstMessage || "No content"}
</div>
</div>
{conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
<div className="bg-blue-50 rounded p-2 border border-blue-200">
<div className="text-xs font-medium text-blue-600 mb-0.5">Latest Message</div>
<div className="text-xs text-gray-700 line-clamp-2">
{conversation.lastMessage}
</div>
</div>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex-shrink-0 text-right">
<span className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded-lg font-medium"> <div className="text-xs text-gray-500">
{conversation.requestCount} turns {new Date(conversation.startTime).toLocaleDateString()}
</span> </div>
<div className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center"> <div className="text-xs text-gray-400">
<ChevronRight className="w-4 h-4 text-gray-400" /> {new Date(conversation.startTime).toLocaleTimeString()}
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-2">
<div className="text-gray-600 text-sm bg-blue-50 rounded-lg p-3 border border-blue-200">
<strong>First:</strong> {conversation.firstMessage.substring(0, 200) || "No content"}{conversation.firstMessage.length > 200 && "..."}
</div>
{conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && (
<div className="text-gray-600 text-sm bg-gray-50 rounded-lg p-3 border border-gray-200">
<strong>Latest:</strong> {conversation.lastMessage.substring(0, 200)}{conversation.lastMessage.length > 200 && "..."}
</div>
)}
</div>
</div> </div>
))} ))}
{hasMoreConversations && ( {hasMoreConversations && (
<div className="p-4 text-center"> <div className="p-3 text-center border-t border-gray-100">
<button <button
onClick={() => loadConversations(modelFilter, true)} onClick={() => loadConversations(true)}
disabled={isFetching} disabled={isFetching}
className="px-4 py-2 text-sm font-semibold text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:bg-blue-300 transition-colors" className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 disabled:opacity-50 transition-colors"
> >
{isFetching ? "Loading..." : "Load More"} {isFetching ? "Loading..." : "Load More"}
</button> </button>

View file

@ -2,170 +2,39 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { * {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
} }
body { body {
background-color: #f9fafb; background-color: #fafafa;
color: #101828; color: #111;
} }
.card { @layer utilities {
background: white; .line-clamp-2 {
border: 1px solid #eaecf0; overflow: hidden;
border-radius: 12px; display: -webkit-box;
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1), 0 1px 2px 0 rgba(16, 24, 40, 0.06); -webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
} }
.card-hover {
transition: all 0.2s ease;
}
.card-hover:hover {
box-shadow: 0 4px 6px -1px rgba(16, 24, 40, 0.1), 0 2px 4px -1px rgba(16, 24, 40, 0.06);
border-color: #d0d5dd;
}
.stat-card {
background: white;
border: 1px solid #eaecf0;
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1), 0 1px 2px 0 rgba(16, 24, 40, 0.06);
}
.nav-button {
background: white;
border: 1px solid #d0d5dd;
color: #344054;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-button:hover {
background: #f9fafb;
border-color: #98a2b3;
}
.nav-button-primary {
background: #3b82f6;
border: 1px solid #3b82f6;
color: white;
font-weight: 500;
}
.nav-button-primary:hover {
background: #2563eb;
border-color: #2563eb;
}
.nav-button-danger {
color: #dc2626;
border-color: #fecaca;
background: #fef2f2;
}
.nav-button-danger:hover {
background: #fee2e2;
border-color: #fca5a5;
}
.request-card {
background: white;
border: 1px solid #eaecf0;
border-radius: 8px;
transition: all 0.2s ease;
}
.request-card:hover {
border-color: #3b82f6;
box-shadow: 0 4px 6px -1px rgba(16, 24, 40, 0.1), 0 2px 4px -1px rgba(16, 24, 40, 0.06);
}
.modal-overlay {
background: rgba(16, 24, 40, 0.7);
backdrop-filter: blur(8px);
}
.modal-content {
background: white;
border: 1px solid #eaecf0;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(16, 24, 40, 0.25);
}
.method-badge {
font-weight: 600;
font-size: 0.75rem;
letter-spacing: 0.025em;
text-transform: uppercase;
border-radius: 6px;
padding: 4px 8px;
}
.code-block { .code-block {
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.875rem; font-size: 0.75rem;
line-height: 1.6; line-height: 1.5;
background: #f9fafb; background: #f5f5f5;
border: 1px solid #eaecf0; border: 1px solid #e5e5e5;
border-radius: 8px; border-radius: 4px;
} }
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.header-blur {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
border-bottom: 1px solid #eaecf0;
}
.section-header {
background: #f9fafb;
border-bottom: 1px solid #eaecf0;
}
.message-role-user {
background: #eff6ff;
border: 1px solid #dbeafe;
border-left: 3px solid #3b82f6;
}
.message-role-assistant {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-left: 3px solid #64748b;
}
.message-role-system {
background: #fffbeb;
border: 1px solid #fef3c7;
border-left: 3px solid #f59e0b;
}
.tool-badge {
background: #ecfdf5;
border: 1px solid #bbf7d0;
color: #047857;
font-weight: 500;
}
.scrollbar-custom { .scrollbar-custom {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9; scrollbar-color: #ddd #f5f5f5;
} }
.scrollbar-custom::-webkit-scrollbar { .scrollbar-custom::-webkit-scrollbar {
@ -174,65 +43,16 @@ body {
} }
.scrollbar-custom::-webkit-scrollbar-track { .scrollbar-custom::-webkit-scrollbar-track {
background: #f1f5f9; background: #f5f5f5;
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-custom::-webkit-scrollbar-thumb { .scrollbar-custom::-webkit-scrollbar-thumb {
background: #cbd5e1; background: #ddd;
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-custom::-webkit-scrollbar-thumb:hover { .scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #94a3b8; background: #ccc;
} }
.scrollbar-dark {
scrollbar-width: thin;
scrollbar-color: #4b5563 #374151;
}
.scrollbar-dark::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-dark::-webkit-scrollbar-track {
background: #374151;
border-radius: 3px;
}
.scrollbar-dark::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 3px;
}
.scrollbar-dark::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.text-primary {
color: #101828;
}
.text-secondary {
color: #475467;
}
.text-tertiary {
color: #667085;
}
.icon-primary {
color: #475467;
}
.icon-accent {
color: #3b82f6;
}