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 (
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.28
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
)
require github.com/felixge/httpsnoop v1.0.3 // indirect

View file

@ -34,9 +34,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"),

View file

@ -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)

View file

@ -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"`

View file

@ -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
}

View file

@ -73,15 +73,6 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
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 := `
INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -96,7 +87,7 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e
string(bodyJSON),
request.UserAgent,
request.ContentType,
modelName,
request.Model,
)
if err != nil {

View file

@ -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 = [];
// 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;
// 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;
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>

View file

@ -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;
}