Merge pull request #3 from seifghazi/feat/proxy-v2
Feat: implement proxy v2 with backend and frontend enhancements
This commit is contained in:
commit
2978d0617d
8 changed files with 482 additions and 832 deletions
|
|
@ -5,6 +5,7 @@ go 1.20
|
|||
require (
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ func Load() (*Config, error) {
|
|||
cfg := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "3001"),
|
||||
ReadTimeout: getDuration("READ_TIMEOUT", 500*time.Second),
|
||||
WriteTimeout: getDuration("WRITE_TIMEOUT", 500*time.Second),
|
||||
IdleTimeout: getDuration("IDLE_TIMEOUT", 500*time.Second),
|
||||
ReadTimeout: getDuration("READ_TIMEOUT", 600*time.Second), // Increased to 10 minutes
|
||||
WriteTimeout: getDuration("WRITE_TIMEOUT", 600*time.Second), // Increased to 10 minutes
|
||||
IdleTimeout: getDuration("IDLE_TIMEOUT", 600*time.Second), // Increased to 10 minutes
|
||||
},
|
||||
Anthropic: AnthropicConfig{
|
||||
BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"),
|
||||
|
|
|
|||
|
|
@ -40,80 +40,9 @@ func New(anthropicService service.AnthropicService, storageService service.Stora
|
|||
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("🤖 Chat completion request received (OpenAI format)")
|
||||
|
||||
bodyBytes := getBodyBytes(r)
|
||||
if bodyBytes == nil {
|
||||
http.Error(w, "Error reading request body", 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)
|
||||
// This endpoint is for compatibility but we're an Anthropic proxy
|
||||
// Return a helpful error message
|
||||
writeErrorResponse(w, "This is an Anthropic proxy. Please use the /v1/messages endpoint instead of /v1/chat/completions", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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()
|
||||
startTime := time.Now()
|
||||
|
||||
|
|
@ -165,7 +82,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
log.Printf("❌ Error forwarding to Anthropic API: %v", err)
|
||||
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 toolCalls []model.ContentBlock
|
||||
var streamingChunks []string
|
||||
var finalUsage *model.AnthropicUsage
|
||||
var messageID string
|
||||
var modelName string
|
||||
var stopReason string
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
|
|
@ -369,12 +290,74 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
|
|||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
case "content_block_delta":
|
||||
if event.Delta != nil {
|
||||
|
|
@ -391,8 +374,7 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
|
|||
toolCalls = append(toolCalls, *event.ContentBlock)
|
||||
}
|
||||
case "message_stop":
|
||||
// End of stream
|
||||
break
|
||||
// End of stream - scanner will exit on its own
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -405,19 +387,41 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
|
|||
CompletedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Create a structured body for the log
|
||||
var responseBody model.AnthropicMessage
|
||||
responseBody.Role = "assistant"
|
||||
var contentBlocks []model.ContentBlock
|
||||
// Create a structured response body that matches Anthropic's format
|
||||
var contentBlocks []model.AnthropicContentBlock
|
||||
if fullResponseText.Len() > 0 {
|
||||
contentBlocks = append(contentBlocks, model.ContentBlock{
|
||||
contentBlocks = append(contentBlocks, model.AnthropicContentBlock{
|
||||
Type: "text",
|
||||
Text: fullResponseText.String(),
|
||||
})
|
||||
}
|
||||
contentBlocks = append(contentBlocks, toolCalls...)
|
||||
responseBody.Content = contentBlocks
|
||||
responseLog.Body = responseBody
|
||||
|
||||
// Create an AnthropicResponse-like structure for consistency
|
||||
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
|
||||
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) {
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error reading Anthropic response: %v", err)
|
||||
|
|
@ -439,21 +447,35 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
|
|||
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{
|
||||
StatusCode: resp.StatusCode,
|
||||
Headers: SanitizeHeaders(resp.Header),
|
||||
BodyText: string(responseBytes),
|
||||
ResponseTime: time.Since(startTime).Milliseconds(),
|
||||
IsStreaming: false,
|
||||
CompletedAt: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Try to parse as JSON for structured logging
|
||||
if resp.Header.Get("Content-Type") == "application/json" {
|
||||
var jsonBody interface{}
|
||||
if json.Unmarshal(responseBytes, &jsonBody) == nil {
|
||||
responseLog.Body = jsonBody
|
||||
// Parse the response as AnthropicResponse for consistent structure
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var anthropicResp model.AnthropicResponse
|
||||
if err := json.Unmarshal(responseBytes, &anthropicResp); err == nil {
|
||||
// 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
|
||||
|
|
@ -474,6 +496,14 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
|
|||
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 {
|
||||
bytes := make([]byte, 8)
|
||||
rand.Read(bytes)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ type RequestLog struct {
|
|||
type ResponseLog struct {
|
||||
StatusCode int `json:"statusCode"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
ResponseTime int64 `json:"responseTime"`
|
||||
StreamingChunks []string `json:"streamingChunks,omitempty"`
|
||||
|
|
@ -60,25 +60,23 @@ type ChatCompletionRequest struct {
|
|||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []Choice `json:"choices"`
|
||||
Usage Usage `json:"usage"`
|
||||
type AnthropicUsage struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Index int `json:"index"`
|
||||
Message ChatMessage `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
type Usage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
type AnthropicResponse struct {
|
||||
Content []AnthropicContentBlock `json:"content"`
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Role string `json:"role"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence *string `json:"stop_sequence"`
|
||||
Type string `json:"type"`
|
||||
Usage AnthropicUsage `json:"usage"`
|
||||
}
|
||||
|
||||
type AnthropicContentBlock struct {
|
||||
|
|
@ -179,7 +177,6 @@ type ErrorResponse struct {
|
|||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
type StreamingEvent struct {
|
||||
Type string `json:"type"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -13,12 +12,10 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/model"
|
||||
)
|
||||
|
||||
type AnthropicService interface {
|
||||
ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error)
|
||||
GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error)
|
||||
ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
type anthropicService struct {
|
||||
|
|
@ -29,263 +26,97 @@ type anthropicService struct {
|
|||
func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService {
|
||||
return &anthropicService{
|
||||
client: &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Timeout: 300 * time.Second, // Increased timeout to 5 minutes
|
||||
},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *anthropicService) ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error) {
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("API key not provided")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
func (s *anthropicService) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
proxyReq := originalReq.Clone(ctx)
|
||||
|
||||
// Parse the configured base URL
|
||||
baseURL, err := url.Parse(s.config.BaseURL)
|
||||
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 == "" {
|
||||
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")
|
||||
fullURL := baseURL.String()
|
||||
// Update the destination URL
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
// Preserve query parameters from original request
|
||||
proxyReq.URL.RawQuery = originalReq.URL.RawQuery
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", apiKey)
|
||||
req.Header.Set("anthropic-version", s.config.Version)
|
||||
// Clear fields that can't be set in client requests
|
||||
proxyReq.RequestURI = "" // This is set by the server and must be cleared
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *anthropicService) GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error) {
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("API key not provided")
|
||||
}
|
||||
|
||||
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)
|
||||
func (s *anthropicService) decompressGzipResponse(resp *http.Response) (*http.Response, error) {
|
||||
// Create a gzip reader
|
||||
gzipReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send grading request: %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))
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
|
||||
var claudeResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
// Read the decompressed data
|
||||
decompressedData, err := io.ReadAll(gzipReader)
|
||||
if err != nil {
|
||||
gzipReader.Close()
|
||||
return nil, fmt.Errorf("failed to read decompressed data: %w", err)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&claudeResponse); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
// Close the gzip reader and original body
|
||||
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 {
|
||||
return nil, fmt.Errorf("empty response from Claude")
|
||||
}
|
||||
// Remove Content-Encoding header since we've decompressed
|
||||
newResp.Header.Del("Content-Encoding")
|
||||
|
||||
return s.parseGradingResponse(claudeResponse.Content[0].Text)
|
||||
}
|
||||
|
||||
func (s *anthropicService) extractUserContent(messages []model.AnthropicMessage) []string {
|
||||
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
|
||||
// Set the decompressed body
|
||||
newResp.Body = io.NopCloser(strings.NewReader(string(decompressedData)))
|
||||
|
||||
return newResp, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,20 +73,6 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
|
|||
return "", fmt.Errorf("failed to marshal body: %w", err)
|
||||
}
|
||||
|
||||
// Model should already be set by the handler
|
||||
modelName := request.Model
|
||||
if modelName == "" {
|
||||
// Defensive fallback: try to extract from body if somehow not set
|
||||
if body, ok := request.Body.(map[string]interface{}); ok {
|
||||
if model, ok := body["model"].(string); ok {
|
||||
modelName = model
|
||||
request.Model = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("🔧 Saving request with model: '%s' (ID: %s)", modelName, request.RequestID)
|
||||
|
||||
query := `
|
||||
INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
|
@ -101,7 +87,7 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
|
|||
string(bodyJSON),
|
||||
request.UserAgent,
|
||||
request.ContentType,
|
||||
modelName,
|
||||
request.Model,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -68,7 +68,16 @@ interface Request {
|
|||
response?: {
|
||||
statusCode: number;
|
||||
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;
|
||||
responseTime: number;
|
||||
streamingChunks?: string[];
|
||||
|
|
@ -222,17 +231,13 @@ export default function Index() {
|
|||
}
|
||||
};
|
||||
|
||||
const loadConversations = async (filter?: string, loadMore = false) => {
|
||||
const loadConversations = async (loadMore = false) => {
|
||||
setIsFetching(true);
|
||||
const pageToFetch = loadMore ? conversationsCurrentPage + 1 : 1;
|
||||
try {
|
||||
const currentModelFilter = filter || modelFilter;
|
||||
const url = new URL('/api/conversations', window.location.origin);
|
||||
url.searchParams.append("page", pageToFetch.toString());
|
||||
url.searchParams.append("limit", itemsPerPage.toString());
|
||||
if (currentModelFilter !== "all") {
|
||||
url.searchParams.append("model", currentModelFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
if (!response.ok) {
|
||||
|
|
@ -322,39 +327,39 @@ export default function Index() {
|
|||
};
|
||||
|
||||
const getRequestSummary = (request: Request) => {
|
||||
if (request.body?.messages) {
|
||||
const messageCount = request.body.messages.length;
|
||||
const parts = [];
|
||||
|
||||
// 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
|
||||
const toolCalls = request.body.messages.reduce((count, msg) => {
|
||||
if (msg.content && Array.isArray(msg.content)) {
|
||||
return count + msg.content.filter((c: any) => c.type === 'tool_use').length;
|
||||
if (totalTokens > 0) {
|
||||
parts.push(`🪙 ${totalTokens.toLocaleString()} tokens`);
|
||||
|
||||
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) => {
|
||||
|
|
@ -495,7 +500,7 @@ export default function Index() {
|
|||
if (viewMode === 'requests') {
|
||||
loadRequests(modelFilter);
|
||||
} else {
|
||||
loadConversations(modelFilter);
|
||||
loadConversations();
|
||||
}
|
||||
}, [viewMode, modelFilter]);
|
||||
|
||||
|
|
@ -504,139 +509,122 @@ export default function Index() {
|
|||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<header className="sticky top-0 z-40 bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-6 py-3">
|
||||
<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-2">
|
||||
<button
|
||||
onClick={() => loadRequests()}
|
||||
className="p-2 rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={clearRequests}
|
||||
className="p-2 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
|
||||
title="Clear all requests"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Claude Code Monitor</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => loadRequests()}
|
||||
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={clearRequests}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
title="Clear all requests"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="mb-6 flex justify-center">
|
||||
<div className="p-1 bg-gray-200 rounded-full flex items-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="inline-flex items-center bg-gray-100 rounded p-0.5">
|
||||
<button
|
||||
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"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4 inline mr-1" />
|
||||
Requests
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 inline mr-1" />
|
||||
Conversations
|
||||
</button>
|
||||
</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 className="max-w-7xl mx-auto px-6 py-8 space-y-8">
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-6">
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<div className="mb-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-gray-500">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{viewMode === "requests" ? "Total Requests" : "Total Conversations"}
|
||||
</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}
|
||||
</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>
|
||||
|
|
@ -645,108 +633,101 @@ export default function Index() {
|
|||
{/* Main Content */}
|
||||
{viewMode === "requests" ? (
|
||||
/* Request History */
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<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 space-x-3">
|
||||
<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> */}
|
||||
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Request History</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{(isFetching && requestsCurrentPage === 1) || isPending ? (
|
||||
<div className="p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-gray-400" />
|
||||
<p className="mt-4 text-sm text-gray-500">Loading requests...</p>
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="w-6 h-6 mx-auto animate-spin text-gray-400" />
|
||||
<p className="mt-2 text-xs text-gray-500">Loading requests...</p>
|
||||
</div>
|
||||
) : filteredRequests.length === 0 ? (
|
||||
<div className="p-12 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">
|
||||
<Inbox className="w-10 h-10 text-gray-400" />
|
||||
</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 className="p-8 text-center text-gray-500">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-1">No requests found</h3>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{filteredRequests.map(request => (
|
||||
<div key={request.id} className="p-6 hover:bg-gray-50 transition-colors cursor-pointer" onClick={() => showRequestDetails(request.id)}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<span className={`method-badge ${getMethodColor(request.method)} px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide`}>
|
||||
{request.method}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-900 font-semibold text-base">{request.endpoint}</span>
|
||||
{request.conversationId && (
|
||||
<span className="text-xs bg-purple-50 border border-purple-200 text-purple-700 px-2 py-1 rounded-full">
|
||||
Turn {request.turnNumber}
|
||||
<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-start justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
{/* Model and Status */}
|
||||
<div className="flex items-center space-x-3 mb-1">
|
||||
<h3 className="text-sm font-medium">
|
||||
{request.body?.model ? (
|
||||
request.body.model.includes('opus') ? <span className="text-purple-600 font-semibold">Opus</span> :
|
||||
request.body.model.includes('sonnet') ? <span className="text-indigo-600 font-semibold">Sonnet</span> :
|
||||
request.body.model.includes('haiku') ? <span className="text-teal-600 font-semibold">Haiku</span> :
|
||||
<span className="text-gray-900">{request.body.model.split('-')[0]}</span>
|
||||
) : <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>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-gray-500 text-sm">{new Date(request.timestamp).toLocaleString()}</span>
|
||||
{request.response.body.usage.cache_read_input_tokens && (
|
||||
<span className="font-mono bg-green-50 text-green-700 px-1.5 py-0.5 rounded">
|
||||
{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 className="flex items-center space-x-3">
|
||||
{request.body?.model && (
|
||||
<span className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded-lg font-medium">
|
||||
{request.body.model}
|
||||
</span>
|
||||
)}
|
||||
{/* {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 className="flex-shrink-0 text-right">
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(request.timestamp).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{new Date(request.timestamp).toLocaleTimeString()}
|
||||
</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>
|
||||
))}
|
||||
{hasMoreRequests && (
|
||||
<div className="p-4 text-center">
|
||||
<div className="p-3 text-center border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => loadRequests(modelFilter, true)}
|
||||
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"}
|
||||
</button>
|
||||
|
|
@ -758,73 +739,77 @@ export default function Index() {
|
|||
</div>
|
||||
) : (
|
||||
/* Conversations View */
|
||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<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 className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="text-sm font-semibold text-gray-900 uppercase tracking-wider">Conversations</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{(isFetching && conversationsCurrentPage === 1) || isPending ? (
|
||||
<div className="p-12 text-center">
|
||||
<Loader2 className="w-8 h-8 mx-auto animate-spin text-gray-400" />
|
||||
<p className="mt-4 text-sm text-gray-500">Loading conversations...</p>
|
||||
<div className="p-8 text-center">
|
||||
<Loader2 className="w-6 h-6 mx-auto animate-spin text-gray-400" />
|
||||
<p className="mt-2 text-xs text-gray-500">Loading conversations...</p>
|
||||
</div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="p-12 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">
|
||||
<MessageCircle className="w-10 h-10 text-gray-400" />
|
||||
</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 className="p-8 text-center text-gray-500">
|
||||
<h3 className="text-sm font-medium text-gray-600 mb-1">No conversations found</h3>
|
||||
<p className="text-xs text-gray-500">Start a conversation to see it appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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 className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-4 flex-1">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
||||
<MessageCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-gray-900 font-semibold text-base">Conversation {conversation.id.slice(-8)}</span>
|
||||
<span className="text-gray-500 text-sm">{new Date(conversation.startTime).toLocaleString()}</span>
|
||||
<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-start justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="text-sm font-semibold text-gray-900 font-mono">
|
||||
#{conversation.id.slice(-8)}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||
{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 && (
|
||||
<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 className="flex items-center space-x-3">
|
||||
<span className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded-lg font-medium">
|
||||
{conversation.requestCount} turns
|
||||
</span>
|
||||
<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 className="flex-shrink-0 text-right">
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(conversation.startTime).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{new Date(conversation.startTime).toLocaleTimeString()}
|
||||
</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>
|
||||
))}
|
||||
{hasMoreConversations && (
|
||||
<div className="p-4 text-center">
|
||||
<div className="p-3 text-center border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => loadConversations(modelFilter, true)}
|
||||
onClick={() => loadConversations(true)}
|
||||
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"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,170 +2,39 @@
|
|||
@tailwind components;
|
||||
@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 {
|
||||
background-color: #f9fafb;
|
||||
color: #101828;
|
||||
background-color: #fafafa;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.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);
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-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 {
|
||||
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #eaecf0;
|
||||
border-radius: 8px;
|
||||
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e5e5e5;
|
||||
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-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
scrollbar-color: #ddd #f5f5f5;
|
||||
}
|
||||
|
||||
.scrollbar-custom::-webkit-scrollbar {
|
||||
|
|
@ -174,65 +43,16 @@ body {
|
|||
}
|
||||
|
||||
.scrollbar-custom::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-custom::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue