This commit is contained in:
Seif Ghazi 2025-06-29 19:27:00 -04:00
commit ae71ec4f72
No known key found for this signature in database
GPG key ID: 4519A4B1EEC1494E
48 changed files with 21032 additions and 0 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Claude Code Monitor Configuration
# Server Configuration
PORT=3001
READ_TIMEOUT=500
WRITE_TIMEOUT=500
IDLE_TIMEOUT=500
# Anthropic API Configuration
# URL to forward requests to (the actual Anthropic API)
ANTHROPIC_FORWARD_URL=https://api.anthropic.com
ANTHROPIC_VERSION=2023-06-01
ANTHROPIC_MAX_RETRIES=3
# Storage Configuration
DATABASE_PATH=requests.db

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# Dependencies
node_modules/
/web/build/
/web/.cache/
# Environment files
.env
.env.*
!.env.example
# Database and logs
requests/*
requests.db
*.log
proxy.log
# Build artifacts
bin/
dist/
*.exe
# IDE and system files
.DS_Store
.vscode/
.idea/
*.swp
*.swo
*~
# Claude-specific files
.claude/
CLAUDE.md
# Test coverage
coverage/
*.cover
*.test
# Temporary files
tmp/
temp/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Claude Code Monitor
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

62
Makefile Normal file
View file

@ -0,0 +1,62 @@
.PHONY: all build run clean install dev
# Default target
all: install build
# Install dependencies
install:
@echo "📦 Installing Go dependencies..."
cd proxy && go mod download
@echo "📦 Installing Node dependencies..."
cd web && npm install
# Build both services
build: build-proxy build-web
build-proxy:
@echo "🔨 Building proxy server..."
cd proxy && go build -o ../bin/proxy cmd/proxy/main.go
build-web:
@echo "🔨 Building web interface..."
cd web && npm run build
# Run in development mode
dev:
@echo "🚀 Starting development servers..."
./run.sh
# Run proxy only
run-proxy:
cd proxy && go run cmd/proxy/main.go
# Run web only
run-web:
cd web && npm run dev
# Clean build artifacts
clean:
@echo "🧹 Cleaning build artifacts..."
rm -rf bin/
rm -rf web/build/
rm -rf web/.cache/
rm -f requests.db
rm -rf requests/
# Database operations
db-reset:
@echo "🗑️ Resetting database..."
rm -f requests.db
rm -rf requests/
# Help
help:
@echo "Claude Code Monitor - Available targets:"
@echo " make install - Install all dependencies"
@echo " make build - Build both services"
@echo " make dev - Run in development mode"
@echo " make run-proxy - Run proxy server only"
@echo " make run-web - Run web interface only"
@echo " make clean - Clean build artifacts"
@echo " make db-reset - Reset database"
@echo " make help - Show this help message"

151
README.md Normal file
View file

@ -0,0 +1,151 @@
# Claude Code Monitor
![Claude Code Monitor Demo](demo.gif)
A dual-purpose monitoring solution that serves as both a proxy for Claude Code requests and a visualization dashboard for your Claude API conversations.
## What It Does
Claude Code Monitor serves two main purposes:
1. **Claude Code Proxy**: Intercepts and monitors requests from Claude Code (claude.ai/code) to the Anthropic API, allowing you to see what Claude Code is doing in real-time
2. **Conversation Viewer**: Displays and analyzes your Claude API conversations with a beautiful web interface
## Features
- **Transparent Proxy**: Routes Claude Code requests through the monitor without disruption
- **Request Monitoring**: SQLite-based logging of all API interactions
- **Live Dashboard**: Real-time visualization of requests and responses
- **Conversation Analysis**: View full conversation threads with tool usage
- **Easy Setup**: One-command startup for both services
## Quick Start
### Prerequisites
- Go 1.20+
- Node.js 18+
- Anthropic API key
- Claude Code
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/yourusername/claude-code-monitor.git
cd claude-code-monitor
```
2. **Set up your API key**
```bash
cp .env.example .env
# Edit .env and add your ANTHROPIC_API_KEY
```
3. **Install and run** (first time)
```bash
make install # Install all dependencies
make dev # Start both services
```
Or use the script that does both:
```bash
./run.sh
```
4. **Subsequent runs** (after initial setup)
```bash
make dev
# or
./run.sh
```
5. **Using with Claude Code**
To use this proxy with Claude Code, set:
```bash
export ANTHROPIC_BASE_URL=http://localhost:3001
```
This will route Claude Code's requests through the proxy for monitoring.
### Access Points
- **Web Dashboard**: http://localhost:5173
- **API Proxy**: http://localhost:3001
- **Health Check**: http://localhost:3001/health
## Advanced Usage
### Running Services Separately
If you need to run services independently:
```bash
# Run proxy only
make run-proxy
# Run web interface only (in another terminal)
make run-web
```
### Available Make Commands
```bash
make install # Install all dependencies
make build # Build both services
make dev # Run in development mode
make clean # Clean build artifacts
make db-reset # Reset database
make help # Show all commands
```
## Configuration
Create a `.env` file with:
```
PORT=3001
DB_PATH=requests.db
ANTHROPIC_FORWARD_URL=https://api.anthropic.com
```
See `.env.example` for all available options.
## Project Structure
```
claude-code-monitor/
├── proxy/ # Go proxy server
│ ├── cmd/ # Application entry points
│ ├── internal/ # Internal packages
│ └── go.mod # Go dependencies
├── web/ # React Remix frontend
│ ├── app/ # Remix application
│ └── package.json # Node dependencies
├── run.sh # Start script
├── .env.example # Environment template
└── README.md # This file
```
## Features in Detail
### Request Monitoring
- All API requests logged to SQLite database
- Searchable request history
- Request/response body inspection
- Conversation threading
### Prompt Analysis
- Automatic prompt grading
- Best practices evaluation
- Complexity assessment
- Response quality metrics
### Web Dashboard
- Real-time request streaming
- Interactive request explorer
- Conversation visualization
- Performance metrics
## License
MIT License - see [LICENSE](LICENSE) for details.

BIN
demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

106
proxy/cmd/proxy/main.go Normal file
View file

@ -0,0 +1,106 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/handler"
"github.com/seifghazi/claude-code-monitor/internal/middleware"
"github.com/seifghazi/claude-code-monitor/internal/service"
)
func main() {
logger := log.New(os.Stdout, "proxy: ", log.LstdFlags|log.Lshortfile)
cfg, err := config.Load()
if err != nil {
logger.Fatalf("❌ Failed to load configuration: %v", err)
}
anthropicService := service.NewAnthropicService(&cfg.Anthropic)
// Use SQLite storage
storageService, err := service.NewSQLiteStorageService(&cfg.Storage)
if err != nil {
logger.Fatalf("❌ Failed to initialize SQLite storage: %v", err)
}
logger.Println("🗿 SQLite database ready")
h := handler.New(anthropicService, storageService, logger)
r := mux.NewRouter()
corsHandler := handlers.CORS(
handlers.AllowedOrigins([]string{"*"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
handlers.AllowedHeaders([]string{"*"}),
)
r.Use(middleware.Logging)
r.HandleFunc("/v1/chat/completions", h.ChatCompletions).Methods("POST")
r.HandleFunc("/v1/messages", h.Messages).Methods("POST")
r.HandleFunc("/v1/models", h.Models).Methods("GET")
r.HandleFunc("/health", h.Health).Methods("GET")
r.HandleFunc("/", h.UI).Methods("GET")
r.HandleFunc("/ui", h.UI).Methods("GET")
r.HandleFunc("/api/requests", h.GetRequests).Methods("GET")
r.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE")
r.HandleFunc("/api/conversations", h.GetConversations).Methods("GET")
r.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET")
r.HandleFunc("/api/conversations/project", h.GetConversationsByProject).Methods("GET")
r.NotFoundHandler = http.HandlerFunc(h.NotFound)
srv := &http.Server{
Addr: ":" + cfg.Server.Port,
Handler: corsHandler(r),
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
IdleTimeout: cfg.Server.IdleTimeout,
}
go func() {
logger.Printf("🚀 Claude Code Monitor Server running on http://localhost:%s", cfg.Server.Port)
logger.Printf("📡 API endpoints available at:")
logger.Printf(" - POST http://localhost:%s/v1/chat/completions (OpenAI format)", cfg.Server.Port)
logger.Printf(" - POST http://localhost:%s/v1/messages (Anthropic format)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/v1/models", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/health", cfg.Server.Port)
logger.Printf(" - POST http://localhost:%s/api/grade-prompt (Prompt grading)", cfg.Server.Port)
logger.Printf("🎨 Web UI available at:")
logger.Printf(" - GET http://localhost:%s/ (Request Visualizer)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/api/requests (Request API)", cfg.Server.Port)
logger.Printf("🔍 All requests logged with comprehensive error handling")
logger.Printf("🎯 Auto prompt grading with Anthropic best practices")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("❌ Server failed to start: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Println("🛑 Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Fatalf("❌ Server forced to shutdown: %v", err)
}
logger.Println("✅ Server exited")
}

13
proxy/go.mod Normal file
View file

@ -0,0 +1,13 @@
module github.com/seifghazi/claude-code-monitor
go 1.20
require (
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
)

8
proxy/go.sum Normal file
View file

@ -0,0 +1,8 @@
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

View file

@ -0,0 +1,87 @@
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
Server ServerConfig
Anthropic AnthropicConfig
Storage StorageConfig
}
type ServerConfig struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
type AnthropicConfig struct {
BaseURL string
Version string
MaxRetries int
}
type StorageConfig struct {
RequestsDir string
DBPath string
}
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),
},
Anthropic: AnthropicConfig{
BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"),
Version: getEnv("ANTHROPIC_VERSION", "2023-06-01"),
MaxRetries: getInt("ANTHROPIC_MAX_RETRIES", 3),
},
Storage: StorageConfig{
DBPath: getEnv("DB_PATH", "requests.db"),
},
}
return cfg, nil
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getDuration(key string, defaultValue time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
duration, err := time.ParseDuration(value)
if err != nil {
return defaultValue
}
return duration
}
func getInt(key string, defaultValue int) int {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}

View file

@ -0,0 +1,689 @@
package handler
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/seifghazi/claude-code-monitor/internal/model"
"github.com/seifghazi/claude-code-monitor/internal/service"
)
type Handler struct {
anthropicService service.AnthropicService
storageService service.StorageService
conversationService service.ConversationService
}
func New(anthropicService service.AnthropicService, storageService service.StorageService, logger *log.Logger) *Handler {
conversationService := service.NewConversationService()
return &Handler{
anthropicService: anthropicService,
storageService: storageService,
conversationService: conversationService,
}
}
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)
}
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
log.Println("🤖 Messages request received (Anthropic format)")
bodyBytes := getBodyBytes(r)
if bodyBytes == nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
var req model.AnthropicRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
log.Printf("❌ Error parsing JSON: %v", err)
writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest)
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()
// Create request log
requestLog := &model.RequestLog{
RequestID: requestID,
Timestamp: time.Now().Format(time.RFC3339),
Method: r.Method,
Endpoint: "/v1/messages",
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)
}
// Forward the request to Anthropic
resp, err := h.anthropicService.ForwardRequest(r.Context(), &req, apiKey)
if err != nil {
log.Printf("❌ Error forwarding to Anthropic API: %v", err)
writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
if req.Stream {
h.handleStreamingResponse(w, resp, requestLog, startTime)
return
}
h.handleNonStreamingResponse(w, resp, requestLog, startTime)
}
func (h *Handler) Models(w http.ResponseWriter, r *http.Request) {
log.Println("📋 Models list requested")
response := &model.ModelsResponse{
Object: "list",
Data: []model.ModelInfo{
{
ID: "claude-3-sonnet-20240229",
Object: "model",
Created: 1677610602,
OwnedBy: "anthropic",
},
{
ID: "claude-3-opus-20240229",
Object: "model",
Created: 1677610602,
OwnedBy: "anthropic",
},
{
ID: "claude-3-haiku-20240307",
Object: "model",
Created: 1677610602,
OwnedBy: "anthropic",
},
},
}
writeJSONResponse(w, response)
}
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
response := &model.HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
}
writeJSONResponse(w, response)
}
func (h *Handler) UI(w http.ResponseWriter, r *http.Request) {
htmlContent, err := os.ReadFile("index.html")
if err != nil {
log.Printf("❌ Error reading index.html: %v", err)
http.Error(w, "UI not available", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(htmlContent)
}
func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 10 // Default limit
}
// Get model filter from query parameters
modelFilter := r.URL.Query().Get("model")
if modelFilter == "" {
modelFilter = "all"
}
log.Printf("📊 GetRequests called - page: %d, limit: %d, modelFilter: %s", page, limit, modelFilter)
// Get all requests with model filter applied at storage level
allRequests, err := h.storageService.GetAllRequests(modelFilter)
if err != nil {
log.Printf("Error getting requests: %v", err)
http.Error(w, "Failed to get requests", http.StatusInternalServerError)
return
}
log.Printf("📊 Got %d requests from storage (filter: %s)", len(allRequests), modelFilter)
// Convert pointers to values for consistency
requests := make([]model.RequestLog, len(allRequests))
for i, req := range allRequests {
if req != nil {
requests[i] = *req
}
}
// Calculate total before pagination
total := len(requests)
// Apply pagination
start := (page - 1) * limit
end := start + limit
if start >= len(requests) {
requests = []model.RequestLog{}
} else {
if end > len(requests) {
end = len(requests)
}
requests = requests[start:end]
}
log.Printf("📊 Returning %d requests after pagination", len(requests))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Requests []model.RequestLog `json:"requests"`
Total int `json:"total"`
}{
Requests: requests,
Total: total,
})
}
func (h *Handler) DeleteRequests(w http.ResponseWriter, r *http.Request) {
log.Println("🗑️ Clearing request history")
clearedCount, err := h.storageService.ClearRequests()
if err != nil {
log.Printf("❌ Error clearing requests: %v", err)
writeErrorResponse(w, "Error clearing request history", http.StatusInternalServerError)
return
}
log.Printf("✅ Deleted %d request files", clearedCount)
response := map[string]interface{}{
"message": "Request history cleared",
"deleted": clearedCount,
}
writeJSONResponse(w, response)
}
func (h *Handler) NotFound(w http.ResponseWriter, r *http.Request) {
writeErrorResponse(w, "Not found", http.StatusNotFound)
}
func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) {
log.Println("🌊 Streaming response detected, forwarding stream...")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
if resp.StatusCode != http.StatusOK {
log.Printf("❌ Anthropic API error: %d", resp.StatusCode)
errorBytes, _ := io.ReadAll(resp.Body)
log.Printf("Error details: %s", string(errorBytes))
responseLog := &model.ResponseLog{
StatusCode: resp.StatusCode,
Headers: SanitizeHeaders(resp.Header),
BodyText: string(errorBytes),
ResponseTime: time.Since(startTime).Milliseconds(),
IsStreaming: true,
CompletedAt: time.Now().Format(time.RFC3339),
}
requestLog.Response = responseLog
if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil {
log.Printf("❌ Error updating request with error response: %v", err)
}
w.WriteHeader(resp.StatusCode)
w.Write(errorBytes)
return
}
var fullResponseText strings.Builder
var toolCalls []model.ContentBlock
var streamingChunks []string
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if line == "" || !strings.HasPrefix(line, "data:") {
continue
}
streamingChunks = append(streamingChunks, line)
fmt.Fprintf(w, "%s\n\n", line)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
jsonData := strings.TrimPrefix(line, "data: ")
var event model.StreamingEvent
if err := json.Unmarshal([]byte(jsonData), &event); err != nil {
log.Printf("⚠️ Error unmarshalling streaming event: %v", err)
continue
}
switch event.Type {
case "content_block_delta":
if event.Delta != nil {
if event.Delta.Type == "text_delta" {
fullResponseText.WriteString(event.Delta.Text)
} else if event.Delta.Type == "input_json_delta" {
if event.Index != nil && *event.Index < len(toolCalls) {
toolCalls[*event.Index].Input = append(toolCalls[*event.Index].Input, event.Delta.Input...)
}
}
}
case "content_block_start":
if event.ContentBlock != nil && event.ContentBlock.Type == "tool_use" {
toolCalls = append(toolCalls, *event.ContentBlock)
}
case "message_stop":
// End of stream
break
}
}
responseLog := &model.ResponseLog{
StatusCode: resp.StatusCode,
Headers: SanitizeHeaders(resp.Header),
StreamingChunks: streamingChunks,
ResponseTime: time.Since(startTime).Milliseconds(),
IsStreaming: true,
CompletedAt: time.Now().Format(time.RFC3339),
}
// Create a structured body for the log
var responseBody model.AnthropicMessage
responseBody.Role = "assistant"
var contentBlocks []model.ContentBlock
if fullResponseText.Len() > 0 {
contentBlocks = append(contentBlocks, model.ContentBlock{
Type: "text",
Text: fullResponseText.String(),
})
}
contentBlocks = append(contentBlocks, toolCalls...)
responseBody.Content = contentBlocks
responseLog.Body = responseBody
requestLog.Response = responseLog
if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil {
log.Printf("❌ Error updating request with streaming response: %v", err)
}
if err := scanner.Err(); err != nil {
log.Printf("❌ Streaming error: %v", err)
} else {
log.Println("✅ Streaming response completed")
}
}
func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) {
responseBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("❌ Error reading Anthropic response: %v", err)
writeErrorResponse(w, "Failed to read response", http.StatusInternalServerError)
return
}
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
}
}
requestLog.Response = responseLog
if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil {
log.Printf("❌ Error updating request with response: %v", err)
}
if resp.StatusCode != http.StatusOK {
log.Printf("❌ Anthropic API error: %d %s", resp.StatusCode, string(responseBytes))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(responseBytes)
return
}
log.Println("✅ Successfully forwarded request to Anthropic API")
w.Header().Set("Content-Type", "application/json")
w.Write(responseBytes)
}
func generateRequestID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
func getBodyBytes(r *http.Request) []byte {
if bodyBytes, ok := r.Context().Value(model.BodyBytesKey).([]byte); ok {
return bodyBytes
}
return nil
}
func writeJSONResponse(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("❌ Error encoding JSON response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeErrorResponse(w http.ResponseWriter, message string, statusCode int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(&model.ErrorResponse{Error: message})
}
// extractTextFromMessage tries multiple strategies to extract text from a message
func extractTextFromMessage(message json.RawMessage) string {
// Strategy 1: Direct string (simple text message)
var directString string
if err := json.Unmarshal(message, &directString); err == nil && directString != "" {
return directString
}
// Strategy 2: Array format [{"type": "text", "text": "..."}]
var msgArray []interface{}
if err := json.Unmarshal(message, &msgArray); err == nil {
for _, item := range msgArray {
if itemMap, ok := item.(map[string]interface{}); ok {
if itemMap["type"] == "text" {
if text, ok := itemMap["text"].(string); ok && text != "" {
return text
}
}
}
}
}
// Strategy 3: Content object format {"content": [{"type": "text", "text": "..."}]}
var msgContent map[string]interface{}
if err := json.Unmarshal(message, &msgContent); err == nil {
if content, ok := msgContent["content"]; ok {
if contentArray, ok := content.([]interface{}); ok {
for _, block := range contentArray {
if blockMap, ok := block.(map[string]interface{}); ok {
if blockMap["type"] == "text" {
if text, ok := blockMap["text"].(string); ok && text != "" {
return text
}
}
}
}
}
}
// Also check if content is a string directly
if contentStr, ok := msgContent["content"].(string); ok && contentStr != "" {
return contentStr
}
}
// Strategy 4: Single object with text field {"type": "text", "text": "..."}
var singleObj map[string]interface{}
if err := json.Unmarshal(message, &singleObj); err == nil {
if singleObj["type"] == "text" {
if text, ok := singleObj["text"].(string); ok && text != "" {
return text
}
}
// Also check for content field at top level
if text, ok := singleObj["content"].(string); ok && text != "" {
return text
}
}
return ""
}
// Conversation handlers
func (h *Handler) GetConversations(w http.ResponseWriter, r *http.Request) {
log.Println("📚 Getting conversations from Claude projects")
conversations, err := h.conversationService.GetConversations()
if err != nil {
log.Printf("❌ Error getting conversations: %v", err)
writeErrorResponse(w, "Failed to get conversations", http.StatusInternalServerError)
return
}
// Flatten all conversations into a single array for the UI
var allConversations []map[string]interface{}
for _, convs := range conversations {
for _, conv := range convs {
// Extract first user message from the conversation
var firstMessage string
for _, msg := range conv.Messages {
if msg.Type == "user" {
// Try multiple parsing strategies
text := extractTextFromMessage(msg.Message)
if text != "" {
firstMessage = text
if len(firstMessage) > 200 {
firstMessage = firstMessage[:200] + "..."
}
break
}
}
}
allConversations = append(allConversations, map[string]interface{}{
"id": conv.SessionID,
"requestCount": conv.MessageCount,
"startTime": conv.StartTime.Format(time.RFC3339),
"lastActivity": conv.EndTime.Format(time.RFC3339),
"duration": conv.EndTime.Sub(conv.StartTime).Milliseconds(),
"firstMessage": firstMessage,
"projectName": conv.ProjectName,
})
}
}
// Sort by last activity (newest first)
sort.Slice(allConversations, func(i, j int) bool {
t1, _ := time.Parse(time.RFC3339, allConversations[i]["lastActivity"].(string))
t2, _ := time.Parse(time.RFC3339, allConversations[j]["lastActivity"].(string))
return t1.After(t2)
})
// Apply pagination
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 10
}
start := (page - 1) * limit
end := start + limit
if start > len(allConversations) {
allConversations = []map[string]interface{}{}
} else {
if end > len(allConversations) {
end = len(allConversations)
}
allConversations = allConversations[start:end]
}
response := map[string]interface{}{
"conversations": allConversations,
}
writeJSONResponse(w, response)
}
func (h *Handler) GetConversationByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sessionID, ok := vars["id"]
if !ok {
http.Error(w, "Session ID is required", http.StatusBadRequest)
return
}
projectPath := r.URL.Query().Get("project")
if projectPath == "" {
http.Error(w, "Project path is required", http.StatusBadRequest)
return
}
log.Printf("📖 Getting conversation %s from project %s", sessionID, projectPath)
conversation, err := h.conversationService.GetConversation(projectPath, sessionID)
if err != nil {
log.Printf("❌ Error getting conversation: %v", err)
http.Error(w, "Conversation not found", http.StatusNotFound)
return
}
writeJSONResponse(w, conversation)
}
func (h *Handler) GetConversationsByProject(w http.ResponseWriter, r *http.Request) {
projectPath := r.URL.Query().Get("project")
if projectPath == "" {
http.Error(w, "Project path is required", http.StatusBadRequest)
return
}
log.Printf("📁 Getting conversations for project %s", projectPath)
conversations, err := h.conversationService.GetConversationsByProject(projectPath)
if err != nil {
log.Printf("❌ Error getting project conversations: %v", err)
writeErrorResponse(w, "Failed to get project conversations", http.StatusInternalServerError)
return
}
writeJSONResponse(w, conversations)
}

View file

@ -0,0 +1,265 @@
package handler
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
// SanitizeHeaders removes sensitive headers before logging/storage
func SanitizeHeaders(headers http.Header) http.Header {
sanitized := make(http.Header)
sensitiveHeaders := []string{
"x-api-key",
"api-key",
"authorization",
"anthropic-api-key",
"openai-api-key",
"bearer",
}
for key, values := range headers {
lowerKey := strings.ToLower(key)
isSensitive := false
for _, sensitive := range sensitiveHeaders {
if strings.Contains(lowerKey, sensitive) {
isSensitive = true
break
}
}
if isSensitive {
sanitized[key] = []string{"[REDACTED]"}
} else {
sanitized[key] = values
}
}
return sanitized
}
// ConversationDiffAnalyzer analyzes conversation flows to identify new vs repeated content
type ConversationDiffAnalyzer struct{}
// NewConversationDiffAnalyzer creates a new conversation diff analyzer
func NewConversationDiffAnalyzer() *ConversationDiffAnalyzer {
return &ConversationDiffAnalyzer{}
}
// ConversationFlowData represents the flow analysis of a conversation
type ConversationFlowData struct {
TotalMessages int `json:"totalMessages"`
NewMessages []int `json:"newMessages"` // Indices of new messages
DuplicateMessages []int `json:"duplicateMessages"` // Indices of duplicate messages
MessageHashes []string `json:"messageHashes"` // Content hashes for deduplication
ConversationHash string `json:"conversationHash"` // Hash of entire conversation
PreviousHash string `json:"previousHash"` // Hash of previous conversation state
Changes []ConversationChange `json:"changes"` // Detailed changes
FlowMetadata map[string]interface{} `json:"flowMetadata"` // Additional metadata
}
// ConversationChange represents a specific change in the conversation
type ConversationChange struct {
Type string `json:"type"` // "added", "modified", "context"
MessageIdx int `json:"messageIdx"` // Index of the message
Role string `json:"role"` // Role of the message
ContentHash string `json:"contentHash"` // Hash of the content
Preview string `json:"preview"` // Short preview of content
Timestamp string `json:"timestamp"` // When this change was detected
}
// AnalyzeConversationFlow analyzes a conversation to identify what's new vs repeated
func (c *ConversationDiffAnalyzer) AnalyzeConversationFlow(messages []model.AnthropicMessage, previousConversation []model.AnthropicMessage) *ConversationFlowData {
totalMessages := len(messages)
// Create hashes for current conversation
currentHashes := make([]string, totalMessages)
for i, msg := range messages {
currentHashes[i] = c.hashMessage(msg)
}
// Create hashes for previous conversation (if any)
var previousHashes []string
if previousConversation != nil {
previousHashes = make([]string, len(previousConversation))
for i, msg := range previousConversation {
previousHashes[i] = c.hashMessage(msg)
}
}
// Identify new vs duplicate messages
newMessages := []int{}
duplicateMessages := []int{}
changes := []ConversationChange{}
// Simple approach: messages that appear after the previous conversation length are new
previousLength := len(previousHashes)
for i, msg := range messages {
isNew := i >= previousLength
// More sophisticated check: compare hashes
if !isNew && i < len(previousHashes) {
isNew = currentHashes[i] != previousHashes[i]
}
if isNew {
newMessages = append(newMessages, i)
changes = append(changes, ConversationChange{
Type: "added",
MessageIdx: i,
Role: msg.Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(msg),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
} else {
duplicateMessages = append(duplicateMessages, i)
changes = append(changes, ConversationChange{
Type: "context",
MessageIdx: i,
Role: msg.Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(msg),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
}
}
// If no previous conversation, consider a reasonable threshold of "new" vs "context"
if previousConversation == nil && totalMessages > 1 {
// Heuristic: last 30% of messages are "new", rest is context
newThreshold := max(1, int(float64(totalMessages)*0.3))
contextEnd := totalMessages - newThreshold
newMessages = []int{}
duplicateMessages = []int{}
changes = []ConversationChange{}
for i := 0; i < totalMessages; i++ {
if i >= contextEnd {
newMessages = append(newMessages, i)
changes = append(changes, ConversationChange{
Type: "added",
MessageIdx: i,
Role: messages[i].Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(messages[i]),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
} else {
duplicateMessages = append(duplicateMessages, i)
changes = append(changes, ConversationChange{
Type: "context",
MessageIdx: i,
Role: messages[i].Role,
ContentHash: currentHashes[i],
Preview: c.getMessagePreview(messages[i]),
Timestamp: fmt.Sprintf("%d", time.Now().Unix()),
})
}
}
}
// Generate conversation hashes
conversationHash := c.hashConversation(messages)
previousHash := ""
if previousConversation != nil {
previousHash = c.hashConversation(previousConversation)
}
return &ConversationFlowData{
TotalMessages: totalMessages,
NewMessages: newMessages,
DuplicateMessages: duplicateMessages,
MessageHashes: currentHashes,
ConversationHash: conversationHash,
PreviousHash: previousHash,
Changes: changes,
FlowMetadata: map[string]interface{}{
"newCount": len(newMessages),
"duplicateCount": len(duplicateMessages),
"analyzeTime": time.Now().Format(time.RFC3339),
},
}
}
// hashMessage creates a hash of a message for deduplication
func (c *ConversationDiffAnalyzer) hashMessage(msg model.AnthropicMessage) string {
// Create a stable representation of the message
content := c.normalizeMessageContent(msg.Content)
data := fmt.Sprintf("%s|%s", msg.Role, content)
hash := sha256.Sum256([]byte(data))
return fmt.Sprintf("%x", hash[:8]) // Use first 8 bytes for shorter hash
}
// hashConversation creates a hash of the entire conversation
func (c *ConversationDiffAnalyzer) hashConversation(messages []model.AnthropicMessage) string {
var parts []string
for _, msg := range messages {
parts = append(parts, c.hashMessage(msg))
}
conversationData := strings.Join(parts, "|")
hash := sha256.Sum256([]byte(conversationData))
return fmt.Sprintf("%x", hash[:16]) // Use first 16 bytes for conversation hash
}
// normalizeMessageContent converts message content to a normalized string
func (c *ConversationDiffAnalyzer) normalizeMessageContent(content interface{}) string {
switch v := content.(type) {
case string:
return strings.TrimSpace(v)
case []interface{}:
var parts []string
for _, item := range v {
if block, ok := item.(map[string]interface{}); ok {
if text, hasText := block["text"].(string); hasText {
parts = append(parts, strings.TrimSpace(text))
} else if blockType, hasType := block["type"].(string); hasType {
// Handle different content types (tool_use, etc.)
switch blockType {
case "tool_use":
if name, hasName := block["name"].(string); hasName {
parts = append(parts, fmt.Sprintf("TOOL:%s", name))
}
case "tool_result":
parts = append(parts, "TOOL_RESULT")
default:
parts = append(parts, fmt.Sprintf("CONTENT:%s", blockType))
}
}
}
}
return strings.Join(parts, " ")
default:
// Convert to JSON and back for normalization
jsonBytes, _ := json.Marshal(content)
return string(jsonBytes)
}
}
// getMessagePreview creates a short preview of a message
func (c *ConversationDiffAnalyzer) getMessagePreview(msg model.AnthropicMessage) string {
content := c.normalizeMessageContent(msg.Content)
if len(content) > 100 {
return content[:100] + "..."
}
return content
}
// max returns the maximum of two integers
func max(a, b int) int {
if a > b {
return a
}
return b
}

View file

@ -0,0 +1,99 @@
package middleware
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("%s - %s %s", start.Format(time.RFC3339), r.Method, r.URL.Path)
log.Printf("Headers: %s", formatHeaders(r.Header))
var bodyBytes []byte
if r.Body != nil {
var err error
bodyBytes, err = io.ReadAll(r.Body)
if err != nil {
log.Printf("❌ Error reading request body: %v", err)
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
ctx := context.WithValue(r.Context(), model.BodyBytesKey, bodyBytes)
r = r.WithContext(ctx)
log.Printf("Body length: %d bytes", len(bodyBytes))
if len(bodyBytes) > 0 {
logRequestBody(bodyBytes)
}
log.Println("---")
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Printf("Response: %d %s (took %v)", wrapped.statusCode, http.StatusText(wrapped.statusCode), duration)
})
}
func formatHeaders(headers http.Header) string {
headerMap := make(map[string][]string)
for k, v := range headers {
headerMap[k] = sanitizeHeaderValue(k, v)
}
headerBytes, _ := json.MarshalIndent(headerMap, "", " ")
return string(headerBytes)
}
func sanitizeHeaderValue(key string, values []string) []string {
lowerKey := strings.ToLower(key)
sensitiveHeaders := []string{
"x-api-key",
"api-key",
"authorization",
"anthropic-api-key",
"openai-api-key",
"bearer",
}
for _, sensitive := range sensitiveHeaders {
if strings.Contains(lowerKey, sensitive) {
return []string{"[REDACTED]"}
}
}
return values
}
func logRequestBody(bodyBytes []byte) {
var bodyJSON interface{}
if err := json.Unmarshal(bodyBytes, &bodyJSON); err == nil {
bodyStr, _ := json.MarshalIndent(bodyJSON, "", " ")
log.Printf("Body: %s", string(bodyStr))
} else {
log.Printf("❌ Failed to parse body as JSON: %v", err)
log.Printf("Raw body: %s", string(bodyBytes))
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}

View file

@ -0,0 +1,203 @@
package model
import (
"encoding/json"
"time"
)
type ContextKey string
const BodyBytesKey ContextKey = "bodyBytes"
type PromptGrade struct {
Score int `json:"score"`
MaxScore int `json:"maxScore"`
Feedback string `json:"feedback"`
ImprovedPrompt string `json:"improvedPrompt"`
Criteria map[string]CriteriaScore `json:"criteria"`
GradingTimestamp string `json:"gradingTimestamp"`
IsProcessing bool `json:"isProcessing"`
}
type CriteriaScore struct {
Score int `json:"score"`
Feedback string `json:"feedback"`
}
type RequestLog struct {
RequestID string `json:"requestId"`
Timestamp string `json:"timestamp"`
Method string `json:"method"`
Endpoint string `json:"endpoint"`
Headers map[string][]string `json:"headers"`
Body interface{} `json:"body"`
Model string `json:"model,omitempty"`
UserAgent string `json:"userAgent"`
ContentType string `json:"contentType"`
PromptGrade *PromptGrade `json:"promptGrade,omitempty"`
Response *ResponseLog `json:"response,omitempty"`
}
type ResponseLog struct {
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
Body interface{} `json:"body,omitempty"`
BodyText string `json:"bodyText,omitempty"`
ResponseTime int64 `json:"responseTime"`
StreamingChunks []string `json:"streamingChunks,omitempty"`
IsStreaming bool `json:"isStreaming"`
CompletedAt string `json:"completedAt"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
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 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 AnthropicContentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
type AnthropicMessage struct {
Role string `json:"role"`
Content interface{} `json:"content"`
}
func (m *AnthropicMessage) GetContentBlocks() []AnthropicContentBlock {
switch v := m.Content.(type) {
case string:
return []AnthropicContentBlock{{Type: "text", Text: v}}
case []interface{}:
var blocks []AnthropicContentBlock
for _, item := range v {
if block, ok := item.(map[string]interface{}); ok {
if typ, hasType := block["type"].(string); hasType {
if text, hasText := block["text"].(string); hasText {
blocks = append(blocks, AnthropicContentBlock{Type: typ, Text: text})
}
}
}
}
return blocks
case []AnthropicContentBlock:
return v
default:
return []AnthropicContentBlock{}
}
}
type AnthropicSystemMessage struct {
Text string `json:"text"`
Type string `json:"type"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
type CacheControl struct {
Type string `json:"type"`
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema InputSchema `json:"input_schema"`
}
type InputSchema struct {
Type string `json:"type"`
Properties map[string]Property `json:"properties"`
Required []string `json:"required,omitempty"`
}
type Property struct {
Type string `json:"type"`
Description string `json:"description"`
}
type AnthropicRequest struct {
Model string `json:"model"`
Messages []AnthropicMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature *float64 `json:"temperature,omitempty"`
System []AnthropicSystemMessage `json:"system,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []Tool `json:"tools,omitempty"`
}
type ModelsResponse struct {
Object string `json:"object"`
Data []ModelInfo `json:"data"`
}
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
}
type GradeRequest struct {
Messages []AnthropicMessage `json:"messages"`
SystemMessages []AnthropicSystemMessage `json:"systemMessages"`
RequestID string `json:"requestId,omitempty"`
}
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
}
type ErrorResponse struct {
Error string `json:"error"`
Details string `json:"details,omitempty"`
}
type StreamingEvent struct {
Type string `json:"type"`
Index *int `json:"index,omitempty"`
Delta *Delta `json:"delta,omitempty"`
ContentBlock *ContentBlock `json:"content_block,omitempty"`
}
type Delta struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
type ContentBlock struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Text string `json:"text,omitempty"`
}

View file

@ -0,0 +1,291 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"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)
}
type anthropicService struct {
client *http.Client
config *config.AnthropicConfig
}
func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService {
return &anthropicService{
client: &http.Client{
Timeout: 60 * time.Second,
},
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")
}
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)
}
if baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("invalid anthropic base URL, scheme and host are required: %s", s.config.BaseURL)
}
baseURL.Path = path.Join(baseURL.Path, "/v1/messages")
fullURL := baseURL.String()
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", s.config.Version)
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
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)
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))
}
var claudeResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.NewDecoder(resp.Body).Decode(&claudeResponse); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if len(claudeResponse.Content) == 0 {
return nil, fmt.Errorf("empty response from Claude")
}
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
}

View file

@ -0,0 +1,306 @@
package service
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type ConversationService interface {
GetConversations() (map[string][]*Conversation, error)
GetConversation(projectPath, sessionID string) (*Conversation, error)
GetConversationsByProject(projectPath string) ([]*Conversation, error)
}
type conversationService struct {
claudeProjectsPath string
}
func NewConversationService() ConversationService {
homeDir, _ := os.UserHomeDir()
return &conversationService{
claudeProjectsPath: filepath.Join(homeDir, ".claude", "projects"),
}
}
// ConversationMessage represents a single message in a Claude conversation
type ConversationMessage struct {
ParentUUID *string `json:"parentUuid"`
IsSidechain bool `json:"isSidechain"`
UserType string `json:"userType"`
CWD string `json:"cwd"`
SessionID string `json:"sessionId"`
Version string `json:"version"`
Type string `json:"type"`
Message json.RawMessage `json:"message"`
UUID string `json:"uuid"`
Timestamp string `json:"timestamp"`
ParsedTime time.Time `json:"-"`
}
// Conversation represents a complete conversation session
type Conversation struct {
SessionID string `json:"sessionId"`
ProjectPath string `json:"projectPath"`
ProjectName string `json:"projectName"`
Messages []*ConversationMessage `json:"messages"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
MessageCount int `json:"messageCount"`
FileModTime time.Time `json:"-"` // Used for sorting, not exported
}
// GetConversations returns all conversations organized by project
func (cs *conversationService) GetConversations() (map[string][]*Conversation, error) {
conversations := make(map[string][]*Conversation)
var parseErrors []string
err := filepath.Walk(cs.claudeProjectsPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Log but don't fail the entire walk
parseErrors = append(parseErrors, fmt.Sprintf("Error accessing %s: %v", path, err))
return nil
}
if !strings.HasSuffix(path, ".jsonl") {
return nil
}
// Get the project path relative to claudeProjectsPath
projectDir := filepath.Dir(path)
projectRelPath, _ := filepath.Rel(cs.claudeProjectsPath, projectDir)
// Skip files directly in the projects directory
if projectRelPath == "." || projectRelPath == "" {
return nil
}
conv, err := cs.parseConversationFile(path, projectRelPath)
if err != nil {
// Log parsing errors but continue processing other files
parseErrors = append(parseErrors, fmt.Sprintf("Failed to parse %s: %v", path, err))
return nil
}
if conv != nil {
// Include conversations even if they have no messages (edge case)
conversations[projectRelPath] = append(conversations[projectRelPath], conv)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk claude projects: %w", err)
}
// Log any parsing errors encountered
if len(parseErrors) > 0 {
fmt.Printf("Warning: Encountered %d parsing errors while loading conversations:\n", len(parseErrors))
for i, err := range parseErrors {
if i < 5 { // Only show first 5 errors to avoid spam
fmt.Printf(" - %s\n", err)
}
}
if len(parseErrors) > 5 {
fmt.Printf(" ... and %d more errors\n", len(parseErrors)-5)
}
}
// Sort conversations within each project by file modification time (newest first)
for project := range conversations {
sort.Slice(conversations[project], func(i, j int) bool {
return conversations[project][i].FileModTime.After(conversations[project][j].FileModTime)
})
}
return conversations, nil
}
// GetConversation returns a specific conversation by project and session ID
func (cs *conversationService) GetConversation(projectPath, sessionID string) (*Conversation, error) {
filePath := filepath.Join(cs.claudeProjectsPath, projectPath, sessionID+".jsonl")
conv, err := cs.parseConversationFile(filePath, projectPath)
if err != nil {
return nil, fmt.Errorf("failed to parse conversation: %w", err)
}
return conv, nil
}
// GetConversationsByProject returns all conversations for a specific project
func (cs *conversationService) GetConversationsByProject(projectPath string) ([]*Conversation, error) {
var conversations []*Conversation
projectDir := filepath.Join(cs.claudeProjectsPath, projectPath)
files, err := os.ReadDir(projectDir)
if err != nil {
return nil, fmt.Errorf("failed to read project directory: %w", err)
}
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".jsonl") {
continue
}
filePath := filepath.Join(projectDir, file.Name())
conv, err := cs.parseConversationFile(filePath, projectPath)
if err != nil {
continue
}
if conv != nil && len(conv.Messages) > 0 {
conversations = append(conversations, conv)
}
}
// Sort by file modification time (newest first)
sort.Slice(conversations, func(i, j int) bool {
return conversations[i].FileModTime.After(conversations[j].FileModTime)
})
return conversations, nil
}
// parseConversationFile reads and parses a JSONL conversation file
func (cs *conversationService) parseConversationFile(filePath, projectPath string) (*Conversation, error) {
// Get file info for modification time
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
var messages []*ConversationMessage
var parseErrors int
lineNum := 0
scanner := bufio.NewScanner(file)
// Increase buffer size for large messages
const maxScanTokenSize = 10 * 1024 * 1024 // 10MB
buf := make([]byte, maxScanTokenSize)
scanner.Buffer(buf, maxScanTokenSize)
for scanner.Scan() {
lineNum++
line := scanner.Bytes()
// Skip empty lines
if len(line) == 0 {
continue
}
var msg ConversationMessage
if err := json.Unmarshal(line, &msg); err != nil {
parseErrors++
// Log only first few errors to avoid spam
if parseErrors <= 3 {
fmt.Printf("Warning: Failed to parse line %d in %s: %v\n", lineNum, filePath, err)
}
continue
}
// Parse timestamp
if msg.Timestamp != "" {
parsedTime, err := time.Parse(time.RFC3339, msg.Timestamp)
if err != nil {
// Try alternative timestamp formats
parsedTime, err = time.Parse(time.RFC3339Nano, msg.Timestamp)
if err != nil {
fmt.Printf("Warning: Failed to parse timestamp '%s' in %s\n", msg.Timestamp, filePath)
}
}
msg.ParsedTime = parsedTime
}
messages = append(messages, &msg)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanner error: %w", err)
}
if parseErrors > 3 {
fmt.Printf("Warning: Total of %d lines failed to parse in %s\n", parseErrors, filePath)
}
// Return empty conversation if no messages (caller can decide what to do)
if len(messages) == 0 {
// Extract session ID from filename
sessionID := filepath.Base(filePath)
sessionID = strings.TrimSuffix(sessionID, ".jsonl")
// Use the full project path as provided
projectName := projectPath
// If it looks like a file path, extract the last component
if strings.Contains(projectPath, "-") {
// This handles cases like "-Users-seifghazi-dev-llm-proxy"
projectName = projectPath
}
return &Conversation{
SessionID: sessionID,
ProjectPath: projectPath,
ProjectName: projectName,
Messages: messages,
StartTime: time.Time{},
EndTime: time.Time{},
MessageCount: 0,
FileModTime: fileInfo.ModTime(),
}, nil
}
// Sort messages by timestamp
sort.Slice(messages, func(i, j int) bool {
return messages[i].ParsedTime.Before(messages[j].ParsedTime)
})
// Extract session ID from filename
sessionID := filepath.Base(filePath)
sessionID = strings.TrimSuffix(sessionID, ".jsonl")
// Use the full project path as provided
projectName := projectPath
// Find first and last valid timestamps
var startTime, endTime time.Time
for _, msg := range messages {
if !msg.ParsedTime.IsZero() {
if startTime.IsZero() || msg.ParsedTime.Before(startTime) {
startTime = msg.ParsedTime
}
if endTime.IsZero() || msg.ParsedTime.After(endTime) {
endTime = msg.ParsedTime
}
}
}
// If no valid timestamps found, use file modification time
if startTime.IsZero() {
startTime = fileInfo.ModTime()
endTime = fileInfo.ModTime()
}
return &Conversation{
SessionID: sessionID,
ProjectPath: projectPath,
ProjectName: projectName,
Messages: messages,
StartTime: startTime,
EndTime: endTime,
MessageCount: len(messages),
FileModTime: fileInfo.ModTime(),
}, nil
}

View file

@ -0,0 +1,18 @@
package service
import (
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type StorageService interface {
SaveRequest(request *model.RequestLog) (string, error)
GetRequests(page, limit int) ([]model.RequestLog, int, error)
ClearRequests() (int, error)
UpdateRequestWithGrading(requestID string, grade *model.PromptGrade) error
UpdateRequestWithResponse(request *model.RequestLog) error
EnsureDirectoryExists() error
GetRequestByShortID(shortID string) (*model.RequestLog, string, error)
GetConfig() *config.StorageConfig
GetAllRequests(modelFilter string) ([]*model.RequestLog, error)
}

View file

@ -0,0 +1,386 @@
package service
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"strings"
_ "github.com/mattn/go-sqlite3"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
type sqliteStorageService struct {
db *sql.DB
config *config.StorageConfig
}
func NewSQLiteStorageService(cfg *config.StorageConfig) (StorageService, error) {
db, err := sql.Open("sqlite3", cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
service := &sqliteStorageService{
db: db,
config: cfg,
}
if err := service.createTables(); err != nil {
return nil, fmt.Errorf("failed to create tables: %w", err)
}
return service, nil
}
func (s *sqliteStorageService) createTables() error {
schema := `
CREATE TABLE IF NOT EXISTS requests (
id TEXT PRIMARY KEY,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
method TEXT NOT NULL,
endpoint TEXT NOT NULL,
headers TEXT NOT NULL,
body TEXT NOT NULL,
user_agent TEXT,
content_type TEXT,
prompt_grade TEXT,
response TEXT,
model TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON requests(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_endpoint ON requests(endpoint);
CREATE INDEX IF NOT EXISTS idx_model ON requests(model);
`
_, err := s.db.Exec(schema)
return err
}
func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, error) {
headersJSON, err := json.Marshal(request.Headers)
if err != nil {
return "", fmt.Errorf("failed to marshal headers: %w", err)
}
bodyJSON, err := json.Marshal(request.Body)
if err != nil {
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err = s.db.Exec(query,
request.RequestID,
request.Timestamp,
request.Method,
request.Endpoint,
string(headersJSON),
string(bodyJSON),
request.UserAgent,
request.ContentType,
modelName,
)
if err != nil {
return "", fmt.Errorf("failed to insert request: %w", err)
}
return request.RequestID, nil
}
func (s *sqliteStorageService) GetRequests(page, limit int) ([]model.RequestLog, int, error) {
// Get total count
var total int
err := s.db.QueryRow("SELECT COUNT(*) FROM requests").Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("failed to get total count: %w", err)
}
// Get paginated results
offset := (page - 1) * limit
query := `
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response
FROM requests
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
`
rows, err := s.db.Query(query, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to query requests: %w", err)
}
defer rows.Close()
var requests []model.RequestLog
for rows.Next() {
var req model.RequestLog
var headersJSON, bodyJSON string
var promptGradeJSON, responseJSON sql.NullString
err := rows.Scan(
&req.RequestID,
&req.Timestamp,
&req.Method,
&req.Endpoint,
&headersJSON,
&bodyJSON,
&req.Model,
&req.UserAgent,
&req.ContentType,
&promptGradeJSON,
&responseJSON,
)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
// Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
log.Printf("Error unmarshaling headers: %v", err)
continue
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
log.Printf("Error unmarshaling body: %v", err)
continue
}
req.Body = body
if promptGradeJSON.Valid {
var grade model.PromptGrade
if err := json.Unmarshal([]byte(promptGradeJSON.String), &grade); err == nil {
req.PromptGrade = &grade
}
}
if responseJSON.Valid {
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err == nil {
req.Response = &resp
}
}
requests = append(requests, req)
}
return requests, total, nil
}
func (s *sqliteStorageService) ClearRequests() (int, error) {
result, err := s.db.Exec("DELETE FROM requests")
if err != nil {
return 0, fmt.Errorf("failed to clear requests: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get rows affected: %w", err)
}
return int(rowsAffected), nil
}
func (s *sqliteStorageService) UpdateRequestWithGrading(requestID string, grade *model.PromptGrade) error {
gradeJSON, err := json.Marshal(grade)
if err != nil {
return fmt.Errorf("failed to marshal grade: %w", err)
}
query := "UPDATE requests SET prompt_grade = ? WHERE id = ?"
_, err = s.db.Exec(query, string(gradeJSON), requestID)
if err != nil {
return fmt.Errorf("failed to update request with grading: %w", err)
}
return nil
}
func (s *sqliteStorageService) UpdateRequestWithResponse(request *model.RequestLog) error {
responseJSON, err := json.Marshal(request.Response)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
query := "UPDATE requests SET response = ? WHERE id = ?"
_, err = s.db.Exec(query, string(responseJSON), request.RequestID)
if err != nil {
return fmt.Errorf("failed to update request with response: %w", err)
}
return nil
}
func (s *sqliteStorageService) EnsureDirectoryExists() error {
// No directory needed for SQLite
return nil
}
func (s *sqliteStorageService) GetRequestByShortID(shortID string) (*model.RequestLog, string, error) {
query := `
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response
FROM requests
WHERE id LIKE ?
ORDER BY timestamp DESC
LIMIT 1
`
var req model.RequestLog
var headersJSON, bodyJSON string
var promptGradeJSON, responseJSON sql.NullString
err := s.db.QueryRow(query, "%"+shortID).Scan(
&req.RequestID,
&req.Timestamp,
&req.Method,
&req.Endpoint,
&headersJSON,
&bodyJSON,
&req.Model,
&req.UserAgent,
&req.ContentType,
&promptGradeJSON,
&responseJSON,
)
if err == sql.ErrNoRows {
return nil, "", fmt.Errorf("request with ID %s not found", shortID)
}
if err != nil {
return nil, "", fmt.Errorf("failed to query request: %w", err)
}
// Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
return nil, "", fmt.Errorf("failed to unmarshal headers: %w", err)
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
return nil, "", fmt.Errorf("failed to unmarshal body: %w", err)
}
req.Body = body
if promptGradeJSON.Valid {
var grade model.PromptGrade
if err := json.Unmarshal([]byte(promptGradeJSON.String), &grade); err == nil {
req.PromptGrade = &grade
}
}
if responseJSON.Valid {
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err == nil {
req.Response = &resp
}
}
return &req, req.RequestID, nil
}
func (s *sqliteStorageService) GetConfig() *config.StorageConfig {
return s.config
}
func (s *sqliteStorageService) GetAllRequests(modelFilter string) ([]*model.RequestLog, error) {
query := `
SELECT id, timestamp, method, endpoint, headers, body, model, user_agent, content_type, prompt_grade, response
FROM requests
`
args := []interface{}{}
if modelFilter != "" && modelFilter != "all" {
query += " WHERE LOWER(model) LIKE ?"
args = append(args, "%"+strings.ToLower(modelFilter)+"%")
log.Printf("🔍 SQL Query with filter: %s, args: %v", query, args)
}
query += " ORDER BY timestamp DESC"
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query requests: %w", err)
}
defer rows.Close()
var requests []*model.RequestLog
for rows.Next() {
var req model.RequestLog
var headersJSON, bodyJSON string
var promptGradeJSON, responseJSON sql.NullString
err := rows.Scan(
&req.RequestID,
&req.Timestamp,
&req.Method,
&req.Endpoint,
&headersJSON,
&bodyJSON,
&req.Model,
&req.UserAgent,
&req.ContentType,
&promptGradeJSON,
&responseJSON,
)
if err != nil {
log.Printf("Error scanning row: %v", err)
continue
}
log.Printf("🔍 Scanned request - ID: %s, Model: %s", req.RequestID, req.Model)
// Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
log.Printf("Error unmarshaling headers: %v", err)
continue
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
log.Printf("Error unmarshaling body: %v", err)
continue
}
req.Body = body
if promptGradeJSON.Valid {
var grade model.PromptGrade
if err := json.Unmarshal([]byte(promptGradeJSON.String), &grade); err == nil {
req.PromptGrade = &grade
}
}
if responseJSON.Valid {
var resp model.ResponseLog
if err := json.Unmarshal([]byte(responseJSON.String), &resp); err == nil {
req.Response = &resp
}
}
requests = append(requests, &req)
}
return requests, nil
}
func (s *sqliteStorageService) Close() error {
return s.db.Close()
}

91
run.sh Executable file
View file

@ -0,0 +1,91 @@
#!/bin/bash
# Claude Code Monitor - Build and Run Script
set -e
echo "🚀 Claude Code Monitor - Starting Services"
echo "========================================="
# Colors for output
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check if Go is installed
if ! command -v go &> /dev/null; then
echo "❌ Go is not installed. Please install Go 1.20 or higher."
exit 1
fi
# Check if Node.js is installed
if ! command -v node &> /dev/null; then
echo "❌ Node.js is not installed. Please install Node.js 18 or higher."
exit 1
fi
# Check for .env file
if [ ! -f .env ]; then
echo -e "${YELLOW}⚠️ No .env file found. Creating from .env.example...${NC}"
if [ -f .env.example ]; then
cp .env.example .env
echo -e "${GREEN}✅ Created .env file.${NC}"
else
echo "❌ No .env.example file found."
exit 1
fi
fi
# Function to cleanup on exit
cleanup() {
echo -e "\n${YELLOW}Shutting down services...${NC}"
kill $PROXY_PID $WEB_PID 2>/dev/null || true
exit
}
trap cleanup EXIT INT TERM
# Build and start proxy server
echo -e "\n${BLUE}📦 Building proxy server...${NC}"
cd proxy
go mod download
go build -o ../bin/proxy cmd/proxy/main.go
cd ..
echo -e "${GREEN}✅ Proxy server built${NC}"
# Install web dependencies if needed
if [ ! -d "web/node_modules" ]; then
echo -e "\n${BLUE}📦 Installing web dependencies...${NC}"
cd web
npm install
cd ..
echo -e "${GREEN}✅ Web dependencies installed${NC}"
fi
# Start proxy server
echo -e "\n${BLUE}🚀 Starting proxy server on port 3001...${NC}"
./bin/proxy &
PROXY_PID=$!
# Wait for proxy to start
sleep 2
# Start web server
echo -e "${BLUE}🚀 Starting web interface on port 5173...${NC}"
cd web
npm run dev &
WEB_PID=$!
cd ..
echo -e "\n${GREEN}✨ All services started!${NC}"
echo "========================================="
echo -e "📊 Web Dashboard: ${BLUE}http://localhost:5173${NC}"
echo -e "🔌 API Proxy: ${BLUE}http://localhost:3001${NC}"
echo -e "💚 Health Check: ${BLUE}http://localhost:3001/health${NC}"
echo "========================================="
echo -e "${YELLOW}Press Ctrl+C to stop all services${NC}\n"
# Wait for processes
wait

84
web/.eslintrc.cjs Normal file
View file

@ -0,0 +1,84 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ["!**/.server", "!**/.client"],
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
"import/resolver": {
typescript: {},
},
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
},
// Node
{
files: [".eslintrc.cjs"],
env: {
node: true,
},
},
],
};

View file

@ -0,0 +1,103 @@
import React from 'react';
interface CodeDiffProps {
oldCode: string;
newCode: string;
fileName?: string;
}
export function CodeDiff({ oldCode, newCode, fileName }: CodeDiffProps) {
// Split code into lines
const oldLines = oldCode.split('\n');
const newLines = newCode.split('\n');
// Simple diff algorithm - find common prefix and suffix
let start = 0;
let oldEnd = oldLines.length - 1;
let newEnd = newLines.length - 1;
// Find common prefix
while (start <= oldEnd && start <= newEnd && oldLines[start] === newLines[start]) {
start++;
}
// Find common suffix
while (oldEnd >= start && newEnd >= start && oldLines[oldEnd] === newLines[newEnd]) {
oldEnd--;
newEnd--;
}
// Build diff display
const diffLines: Array<{ type: 'unchanged' | 'removed' | 'added'; content: string; lineNum?: number }> = [];
// Add unchanged prefix
for (let i = 0; i < start; i++) {
diffLines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 });
}
// Add removed lines
for (let i = start; i <= oldEnd; i++) {
diffLines.push({ type: 'removed', content: oldLines[i] });
}
// Add added lines
for (let i = start; i <= newEnd; i++) {
diffLines.push({ type: 'added', content: newLines[i], lineNum: i + 1 });
}
// Add unchanged suffix
for (let i = oldEnd + 1; i < oldLines.length; i++) {
diffLines.push({ type: 'unchanged', content: oldLines[i], lineNum: i + 1 + (newEnd - oldEnd) });
}
return (
<div className="rounded-lg border border-gray-700 bg-gray-900 overflow-hidden">
{fileName && (
<div className="px-4 py-2 bg-gray-800 border-b border-gray-700 text-sm text-gray-300">
{fileName}
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm font-mono">
<tbody>
{diffLines.map((line, idx) => (
<tr
key={idx}
className={
line.type === 'removed' ? 'bg-red-900/20' :
line.type === 'added' ? 'bg-green-900/20' :
''
}
>
<td className="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'removed' ? '-' : line.lineNum || ''}
</td>
<td className="px-2 py-0.5 text-right text-gray-500 select-none w-12">
{line.type === 'added' ? '+' : line.type === 'unchanged' ? line.lineNum || '' : ''}
</td>
<td className="px-1 py-0.5 select-none w-6 text-center">
<span className={
line.type === 'removed' ? 'text-red-400' :
line.type === 'added' ? 'text-green-400' :
'text-gray-600'
}>
{line.type === 'removed' ? '-' : line.type === 'added' ? '+' : ' '}
</span>
</td>
<td className="px-2 py-0.5 whitespace-pre overflow-x-auto">
<span className={
line.type === 'removed' ? 'text-red-300' :
line.type === 'added' ? 'text-green-300' :
'text-gray-300'
}>
{line.content}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,236 @@
import { useState } from 'react';
import { Copy, Check, FileCode, Download, Maximize2, X } from 'lucide-react';
interface CodeViewerProps {
code: string;
fileName?: string;
language?: string;
}
export function CodeViewer({ code, fileName, language }: CodeViewerProps) {
const [copied, setCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Determine language from file extension
const getLanguageFromFileName = (filename?: string): string => {
if (!filename) return 'text';
const extension = filename.split('.').pop()?.toLowerCase();
const languageMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'h': 'c',
'hpp': 'cpp',
'cs': 'csharp',
'php': 'php',
'swift': 'swift',
'kt': 'kotlin',
'scala': 'scala',
'r': 'r',
'sh': 'bash',
'bash': 'bash',
'zsh': 'bash',
'fish': 'bash',
'ps1': 'powershell',
'sql': 'sql',
'html': 'html',
'htm': 'html',
'xml': 'xml',
'css': 'css',
'scss': 'scss',
'sass': 'sass',
'less': 'less',
'json': 'json',
'yaml': 'yaml',
'yml': 'yaml',
'toml': 'toml',
'md': 'markdown',
'mdx': 'markdown',
'tex': 'latex',
'dockerfile': 'dockerfile',
'makefile': 'makefile',
'cmake': 'cmake',
'gradle': 'gradle',
'maven': 'xml',
'vim': 'vim',
'lua': 'lua',
'dart': 'dart',
'elixir': 'elixir',
'elm': 'elm',
'erlang': 'erlang',
'haskell': 'haskell',
'julia': 'julia',
'nim': 'nim',
'perl': 'perl',
'ocaml': 'ocaml',
'clj': 'clojure',
'cljs': 'clojure',
'cljc': 'clojure'
};
return languageMap[extension || ''] || 'text';
};
const detectedLanguage = language || getLanguageFromFileName(fileName);
// Basic syntax highlighting for common tokens
const highlightCode = (code: string): string => {
// Escape HTML
let highlighted = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// Common patterns for many languages
const patterns = [
// Strings
{ regex: /(["'`])(?:(?=(\\?))\2.)*?\1/g, class: 'text-green-400' },
// Comments
{ regex: /(\/\/.*$)/gm, class: 'text-gray-500 italic' },
{ regex: /(\/\*[\s\S]*?\*\/)/g, class: 'text-gray-500 italic' },
{ regex: /(#.*$)/gm, class: 'text-gray-500 italic' },
// Numbers
{ regex: /\b(\d+\.?\d*)\b/g, class: 'text-purple-400' },
// Keywords (common across many languages)
{ regex: /\b(function|const|let|var|if|else|for|while|return|class|import|export|from|async|await|def|elif|except|finally|lambda|with|as|raise|del|global|nonlocal|assert|break|continue|try|catch|throw|new|this|super|extends|implements|interface|abstract|static|public|private|protected|void|int|string|boolean|float|double|char|long|short|byte|enum|struct|typedef|union|namespace|using|package|goto|switch|case|default)\b/g, class: 'text-blue-400' },
// Boolean and null values
{ regex: /\b(true|false|null|undefined|nil|None|True|False)\b/g, class: 'text-orange-400' },
// Function calls (basic)
{ regex: /(\w+)(?=\s*\()/g, class: 'text-yellow-400' },
// Types/Classes (PascalCase)
{ regex: /\b([A-Z][a-zA-Z0-9]*)\b/g, class: 'text-cyan-400' },
];
patterns.forEach(({ regex, class: className }) => {
highlighted = highlighted.replace(regex, `<span class="${className}">$&</span>`);
});
return highlighted;
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const handleDownload = () => {
const blob = new Blob([code], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName || 'code.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const lines = code.split('\n');
const lineCount = lines.length;
const CodeDisplay = ({ inModal = false }: { inModal?: boolean }) => (
<div className={`rounded-lg border border-gray-700 bg-gray-900 overflow-hidden ${inModal ? '' : 'max-h-[600px]'}`}>
{/* Header */}
<div className="px-4 py-2 bg-gray-800 border-b border-gray-700 flex items-center justify-between">
<div className="flex items-center space-x-3">
<FileCode className="w-4 h-4 text-blue-400" />
<span className="text-sm text-gray-300 font-mono">
{fileName || 'Untitled'}
</span>
<span className="text-xs text-gray-500 bg-gray-700 px-2 py-1 rounded">
{detectedLanguage}
</span>
<span className="text-xs text-gray-500">
{lineCount} lines
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleDownload}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Download file"
>
<Download className="w-4 h-4" />
</button>
{!inModal && (
<button
onClick={() => setIsFullscreen(true)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="View fullscreen"
>
<Maximize2 className="w-4 h-4" />
</button>
)}
<button
onClick={handleCopy}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Copy code"
>
{copied ? (
<Check className="w-4 h-4 text-green-400" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Code content */}
<div className={`overflow-auto ${inModal ? 'max-h-[80vh]' : 'max-h-[500px]'}`}>
<table className="w-full text-sm font-mono">
<tbody>
{lines.map((line, idx) => (
<tr key={idx} className="hover:bg-gray-800/50">
<td className="px-4 py-0.5 text-right text-gray-500 select-none w-12 align-top">
{idx + 1}
</td>
<td className="px-4 py-0.5 whitespace-pre text-gray-300">
<span dangerouslySetInnerHTML={{ __html: highlightCode(line) }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
return (
<>
<CodeDisplay />
{/* Fullscreen Modal */}
{isFullscreen && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setIsFullscreen(false)}
>
<div className="relative max-w-[90vw] w-full max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setIsFullscreen(false)}
className="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors"
title="Close"
>
<X className="w-6 h-6" />
</button>
<CodeDisplay inModal />
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,215 @@
import { MessageCircle, Clock, Sparkles, ChevronDown, ChevronRight, GitBranch, ArrowRight } from 'lucide-react';
import { useState } from 'react';
import { MessageFlow } from './MessageFlow'
import { formatLargeText } from '../utils/formatters';
interface ConversationThreadProps {
conversation: {
sessionId: string;
projectPath: string;
projectName: string;
messages: Array<{
parentUuid: string | null;
isSidechain: boolean;
userType: string;
cwd: string;
sessionId: string;
version: string;
type: string;
message: any;
uuid: string;
timestamp: string;
}>;
startTime: string;
endTime: string;
messageCount: number;
};
}
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: any;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
export function ConversationThread({ conversation }: ConversationThreadProps) {
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['flow']));
const toggleSection = (section: string) => {
const newExpanded = new Set(expandedSections);
if (newExpanded.has(section)) {
newExpanded.delete(section);
} else {
newExpanded.add(section);
}
setExpandedSections(newExpanded);
};
// Extract all messages and analyze conversation flow from JSONL messages
const analyzeConversationFlow = () => {
const allMessages: ConversationMessage[] = [];
// Check if messages exist
if (!conversation.messages || !Array.isArray(conversation.messages)) {
console.warn('No messages found in conversation:', conversation);
return allMessages;
}
// Convert JSONL messages to conversation messages
conversation.messages.forEach((msg) => {
// Parse the message content
let parsedMessage: any;
try {
parsedMessage = typeof msg.message === 'string' ? JSON.parse(msg.message) : msg.message;
} catch (e) {
parsedMessage = msg.message;
}
// Determine the role based on the type field
let role: 'user' | 'assistant' | 'system' = 'user';
if (msg.type === 'assistant') {
role = 'assistant';
} else if (msg.type === 'system') {
role = 'system';
}
// Extract content based on message structure
let content = null;
if (parsedMessage) {
if (parsedMessage.content) {
content = parsedMessage.content;
} else if (parsedMessage.text) {
content = parsedMessage.text;
} else if (Array.isArray(parsedMessage)) {
content = parsedMessage;
} else if (typeof parsedMessage === 'string') {
content = parsedMessage;
} else {
content = parsedMessage;
}
}
if (content) {
allMessages.push({
role,
content,
timestamp: msg.timestamp,
turnNumber: undefined, // Not available in JSONL format
isNewInTurn: true,
});
}
});
return allMessages;
};
const messages = analyzeConversationFlow();
// Debug logging to identify assistant response issues
console.log('Conversation Debug:', {
messageCount: conversation.messageCount,
totalMessages: messages.length,
messages: messages.map(m => ({
role: m.role,
contentPreview: JSON.stringify(m.content)?.substring(0, 50),
turn: m.turnNumber,
ts: m.timestamp,
})),
});
if (messages.length === 0) {
return (
<div className="text-center py-12">
<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 messages found</h3>
<p className="text-sm text-gray-500">This conversation appears to be empty</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Conversation Flow Header */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => toggleSection('flow')}
>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<GitBranch className="w-5 h-5 text-white" />
</div>
<div>
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-2">
<span>Conversation Flow</span>
<div className="flex items-center space-x-2 text-sm">
<Sparkles className="w-4 h-4 text-purple-500" />
<span className="text-gray-600">
Conversation processed -
<span className="font-semibold text-purple-700"> {messages.length}</span> messages
</span>
</div>
</h4>
<p className="text-sm text-gray-600">
{messages.length} messages {conversation.messageCount} total
</p>
</div>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{new Date(messages[messages.length - 1]?.timestamp).toLocaleTimeString()}
</span>
{expandedSections.has('flow') ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
</div>
</div>
</div>
{/* Conversation Messages */}
{expandedSections.has('flow') && (
<div className="space-y-1">
{messages.map((message, index) => (
<MessageFlow
key={`${conversation.sessionId}-${index}`}
message={message}
index={index}
isLast={index === messages.length - 1}
totalMessages={messages.length}
/>
))}
{/* Conversation Summary */}
<div className="mt-8 p-4 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-200">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Sparkles className="w-4 h-4 text-blue-600" />
</div>
<div>
<div className="text-sm font-medium text-blue-900">Conversation Summary</div>
<div className="text-xs text-blue-700">
{messages.length} messages {conversation.messageCount} total messages
</div>
</div>
</div>
<div className="text-right text-xs text-blue-700">
<div className="flex items-center space-x-1">
<Clock className="w-3 h-3" />
<span>Latest: {new Date().toLocaleTimeString()}</span>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,144 @@
import { useState } from 'react';
import { Image as ImageIcon, Download, Maximize2, X } from 'lucide-react';
interface ImageContentProps {
content: {
source?: {
type: string;
media_type: string;
data: string;
};
data?: string;
media_type?: string;
};
}
export function ImageContent({ content }: ImageContentProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [imageError, setImageError] = useState(false);
// Extract image data and media type
let imageData: string | undefined;
let mediaType: string | undefined;
if (content.source) {
// Claude API format
imageData = content.source.data;
mediaType = content.source.media_type;
} else if (content.data) {
// Alternative format
imageData = content.data;
mediaType = content.media_type || 'image/png';
}
if (!imageData) {
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center space-x-2">
<ImageIcon className="w-4 h-4 text-amber-600" />
<span className="text-amber-700 font-medium text-sm">No image data available</span>
</div>
</div>
);
}
// Ensure the data URI is properly formatted
const dataUri = imageData.startsWith('data:')
? imageData
: `data:${mediaType || 'image/png'};base64,${imageData}`;
const handleDownload = () => {
const link = document.createElement('a');
link.href = dataUri;
link.download = `image-${Date.now()}.${mediaType?.split('/')[1] || 'png'}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
if (imageError) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center space-x-2">
<ImageIcon className="w-4 h-4 text-red-600" />
<span className="text-red-700 font-medium text-sm">Failed to load image</span>
</div>
<details className="mt-2 cursor-pointer">
<summary className="text-xs text-red-600 hover:text-red-800 underline transition-colors">
Show raw data
</summary>
<pre className="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-red-200 font-mono">
{JSON.stringify(content, null, 2)}
</pre>
</details>
</div>
);
}
return (
<>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<ImageIcon className="w-4 h-4 text-blue-600" />
<span className="text-gray-700 font-medium text-sm">
Image ({mediaType || 'unknown type'})
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleDownload}
className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors"
title="Download image"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => setIsFullscreen(true)}
className="p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded transition-colors"
title="View fullscreen"
>
<Maximize2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="bg-white rounded border border-gray-200 p-2">
<img
src={dataUri}
alt="Content image"
className="max-w-full h-auto rounded cursor-pointer"
onClick={() => setIsFullscreen(true)}
onError={() => setImageError(true)}
/>
</div>
</div>
{/* Fullscreen Modal */}
{isFullscreen && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setIsFullscreen(false)}
>
<div className="relative max-w-[90vw] max-h-[90vh]">
<button
onClick={(e) => {
e.stopPropagation();
setIsFullscreen(false);
}}
className="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors"
title="Close"
>
<X className="w-6 h-6" />
</button>
<img
src={dataUri}
alt="Content image (fullscreen)"
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,400 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Wrench, Code, FileText, Database, AlertCircle } from 'lucide-react';
import { ToolResult } from './ToolResult';
import { ToolUse } from './ToolUse';
import { ImageContent } from './ImageContent';
import { formatLargeText } from '../utils/formatters';
interface ContentItem {
type: string;
text?: string;
content?: any;
name?: string;
id?: string;
input?: Record<string, any>;
tool_call_id?: string;
is_error?: boolean;
}
interface MessageContentProps {
content: ContentItem | ContentItem[] | string;
}
export function MessageContent({ content }: MessageContentProps) {
// Handle string content
if (typeof content === 'string') {
// Check if content contains system reminders
if (content.includes('<system-reminder>')) {
return <SystemReminderContent content={content} />;
}
return (
<div
className="text-gray-700 bg-white rounded-lg p-4 border border-gray-200 shadow-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: formatLargeText(content) }}
/>
);
}
// Handle array of content items
if (Array.isArray(content)) {
return (
<div className="space-y-4">
{content.map((item, index) => (
<div key={index} className="content-block">
<MessageContent content={item} />
</div>
))}
</div>
);
}
// Handle single content item
if (content && typeof content === 'object') {
switch (content.type) {
case 'text':
// Check if this text contains tool definitions
if (content.text && content.text.includes('<functions>')) {
return <ToolDefinitions text={content.text} />;
}
// Check if this text contains system reminders
if (content.text && content.text.includes('<system-reminder>')) {
return <SystemReminderContent content={content.text} />;
}
return (
<div
className="text-gray-700 bg-white rounded-lg p-4 border border-gray-200 shadow-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: formatLargeText(content.text || '') }}
/>
);
case 'tool_use':
return (
<ToolUse
name={content.name || 'Unknown Tool'}
id={content.id || 'unknown'}
input={content.input || {}}
text={content.text}
/>
);
case 'tool_result':
// Handle both content.text and content.content structures
const resultContent = content.text || content.content || content;
return (
<ToolResult
content={resultContent}
toolId={content.tool_call_id || content.id}
isError={content.is_error || false}
/>
);
case 'image':
return <ImageContent content={content} />;
default:
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Code className="w-4 h-4 text-amber-600" />
<span className="text-amber-700 font-medium text-sm">Unknown content type: {content.type}</span>
</div>
<details className="cursor-pointer">
<summary className="text-xs text-amber-600 hover:text-amber-800 underline transition-colors">
Show raw content
</summary>
<pre className="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-amber-200 font-mono">
{JSON.stringify(content, null, 2)}
</pre>
</details>
</div>
);
}
}
// Fallback
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<FileText className="w-4 h-4 text-gray-500" />
<span className="text-gray-600 font-medium text-sm">Unable to render content</span>
</div>
<details className="cursor-pointer">
<summary className="text-xs text-blue-600 hover:text-blue-800 underline transition-colors">
Show raw content
</summary>
<pre className="mt-2 text-xs overflow-x-auto bg-white rounded p-3 border border-gray-200 font-mono">
{JSON.stringify(content, null, 2)}
</pre>
</details>
</div>
);
}
// Component to handle tool definitions in system prompts
function ToolDefinitions({ text }: { text: string }) {
const [isExpanded, setIsExpanded] = useState(false);
const functionsMatch = text.match(/<functions>([\s\S]*?)<\/functions>/);
if (!functionsMatch) {
return (
<div
className="text-gray-700 whitespace-pre-wrap bg-white rounded-lg p-4 border border-gray-200 shadow-sm"
dangerouslySetInnerHTML={{ __html: formatLargeText(text) }}
/>
);
}
const functionsText = functionsMatch[1];
const beforeFunctions = text.substring(0, functionsMatch.index!);
const afterFunctions = text.substring(functionsMatch.index! + functionsMatch[0].length);
// Parse individual function definitions
const functionMatches = [...functionsText.matchAll(/<function>([\s\S]*?)<\/function>/g)];
return (
<div className="space-y-4">
{beforeFunctions && (
<div
className="text-gray-700 max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm"
dangerouslySetInnerHTML={{ __html: formatLargeText(beforeFunctions) }}
/>
)}
<div className="bg-gradient-to-r from-emerald-50 to-green-50 border border-emerald-200 rounded-xl p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-emerald-500 to-green-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench className="w-5 h-5 text-white" />
</div>
<div>
<div className="flex items-center space-x-2">
<span className="text-emerald-900 font-semibold text-base">Available Tools</span>
<Database className="w-4 h-4 text-emerald-600" />
</div>
<div className="text-sm text-emerald-700">
{functionMatches.length} tools defined for this conversation
</div>
</div>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center space-x-2 text-xs text-emerald-700 hover:text-emerald-900 bg-white hover:bg-emerald-50 px-3 py-2 rounded-lg border border-emerald-200 transition-all duration-200"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{isExpanded ? 'Hide Tools' : 'Show Tools'}</span>
</button>
</div>
{isExpanded && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{functionMatches.map((match, index) => (
<ToolDefinition key={index} functionText={match[1]} index={index} />
))}
</div>
)}
</div>
{afterFunctions && (
<div
className="text-gray-700 max-h-64 overflow-y-auto bg-white rounded-lg p-4 border border-gray-200 shadow-sm"
dangerouslySetInnerHTML={{ __html: formatLargeText(afterFunctions) }}
/>
)}
</div>
);
}
// Component to render individual tool definition
function ToolDefinition({ functionText, index }: { functionText: string; index: number }) {
const [showDetails, setShowDetails] = useState(false);
try {
const toolDef = JSON.parse(functionText);
const paramCount = toolDef.parameters?.properties ? Object.keys(toolDef.parameters.properties).length : 0;
const requiredParams = toolDef.parameters?.required || [];
return (
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-200">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-emerald-100 rounded-lg flex items-center justify-center">
<Wrench className="w-4 h-4 text-emerald-600" />
</div>
<div>
<span className="text-emerald-700 font-mono text-sm font-semibold">{toolDef.name}</span>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full border border-gray-200">
{paramCount} params
</span>
{requiredParams.length > 0 && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">
{requiredParams.length} required
</span>
)}
</div>
</div>
</div>
<button
onClick={() => setShowDetails(!showDetails)}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
{showDetails ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
</div>
<div className="text-gray-600 text-sm mb-3 leading-relaxed">
{toolDef.description || 'No description available'}
</div>
{showDetails && (
<div className="space-y-3 pt-3 border-t border-gray-200">
{toolDef.parameters?.properties && (
<div>
<div className="text-sm font-medium text-gray-900 mb-2">Parameters:</div>
<div className="space-y-2">
{Object.entries(toolDef.parameters.properties).map(([name, param]: [string, any]) => (
<div key={name} className="flex items-start space-x-3 p-2 bg-gray-50 rounded border border-gray-200">
<div className="flex items-center space-x-2 min-w-0 flex-1">
<span className="font-mono text-xs text-blue-600 font-medium">{name}</span>
{requiredParams.includes(name) ? (
<span className="text-xs bg-red-100 text-red-700 px-1 py-0.5 rounded border border-red-200">
required
</span>
) : (
<span className="text-xs bg-gray-100 text-gray-600 px-1 py-0.5 rounded border border-gray-200">
optional
</span>
)}
<span className="text-xs text-gray-500">{param.type || 'any'}</span>
</div>
<div className="text-xs text-gray-600 flex-1 min-w-0">
{param.description || 'No description'}
</div>
</div>
))}
</div>
</div>
)}
<details className="cursor-pointer">
<summary className="text-xs text-gray-600 hover:text-gray-800 underline transition-colors">
Show raw definition
</summary>
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded text-xs overflow-x-auto font-mono">
{JSON.stringify(toolDef, null, 2)}
</pre>
</details>
</div>
)}
</div>
);
} catch (e) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Code className="w-4 h-4 text-red-600" />
<span className="text-red-700 font-medium text-sm">Invalid Tool Definition #{index + 1}</span>
</div>
<pre className="text-gray-700 text-xs overflow-x-auto bg-white rounded p-3 border border-red-200 font-mono">
{functionText}
</pre>
</div>
);
}
}
// Component to handle system reminder content
function SystemReminderContent({ content }: { content: string }) {
const [showReminders, setShowReminders] = useState(false);
// Split content into regular and system reminder parts
const parts: Array<{ type: 'text' | 'reminder'; content: string }> = [];
const reminderRegex = /<system-reminder>([\s\S]*?)<\/system-reminder>/g;
let lastIndex = 0;
let match;
while ((match = reminderRegex.exec(content)) !== null) {
// Add text before the reminder
if (match.index > lastIndex) {
const textPart = content.substring(lastIndex, match.index).trim();
if (textPart) {
parts.push({ type: 'text', content: textPart });
}
}
// Add the reminder
parts.push({ type: 'reminder', content: match[1].trim() });
lastIndex = match.index + match[0].length;
}
// Add any remaining text
if (lastIndex < content.length) {
const textPart = content.substring(lastIndex).trim();
if (textPart) {
parts.push({ type: 'text', content: textPart });
}
}
const reminderCount = parts.filter(p => p.type === 'reminder').length;
const hasNonReminderContent = parts.some(p => p.type === 'text');
return (
<div className="space-y-3">
{/* Regular content */}
{parts.filter(p => p.type === 'text').map((part, index) => (
<div
key={`text-${index}`}
className="text-gray-700 bg-white rounded-lg p-4 border border-gray-200 shadow-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: formatLargeText(part.content) }}
/>
))}
{/* System reminder indicator/toggle */}
{reminderCount > 0 && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<button
onClick={() => setShowReminders(!showReminders)}
className="flex items-center space-x-2 text-sm text-gray-600 hover:text-gray-800 transition-colors w-full"
>
<AlertCircle className="w-4 h-4 text-gray-500" />
<span className="font-medium">
{reminderCount} system reminder{reminderCount > 1 ? 's' : ''}
</span>
{showReminders ? (
<ChevronDown className="w-4 h-4 ml-auto" />
) : (
<ChevronRight className="w-4 h-4 ml-auto" />
)}
</button>
{/* System reminder content */}
{showReminders && (
<div className="mt-3 space-y-2">
{parts.filter(p => p.type === 'reminder').map((part, index) => (
<div
key={`reminder-${index}`}
className="bg-gray-100 rounded p-3 text-xs text-gray-600 font-mono border border-gray-300"
>
<div className="flex items-start space-x-2">
<AlertCircle className="w-3 h-3 text-gray-500 mt-0.5 flex-shrink-0" />
<div className="overflow-x-auto whitespace-pre-wrap">{part.content}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,282 @@
import { useState } from 'react';
import { User, Bot, Settings, ChevronDown, ChevronRight, Clock, Sparkles, ArrowDown } from 'lucide-react';
import { MessageContent } from './MessageContent';
import { formatLargeText } from '../utils/formatters';
interface ConversationMessage {
role: 'user' | 'assistant' | 'system';
content: any;
timestamp: string;
turnNumber?: number;
isNewInTurn?: boolean;
isDuplicate?: boolean;
}
interface MessageFlowProps {
message: ConversationMessage;
index: number;
isLast: boolean;
totalMessages: number;
}
export function MessageFlow({ message, index, isLast, totalMessages }: MessageFlowProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getRoleConfig = () => {
switch (message.role) {
case 'user':
return {
icon: <User className="w-5 h-5 text-blue-600" />,
bgColor: 'bg-blue-50',
borderColor: 'border-blue-200',
accentColor: 'border-l-blue-500',
textColor: 'text-blue-900',
titleColor: 'text-blue-700',
name: 'User'
};
case 'assistant':
return {
icon: <Bot className="w-5 h-5 text-gray-600" />,
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
accentColor: 'border-l-gray-500',
textColor: 'text-gray-900',
titleColor: 'text-gray-700',
name: 'Assistant'
};
case 'system':
return {
icon: <Settings className="w-5 h-5 text-amber-600" />,
bgColor: 'bg-amber-50',
borderColor: 'border-amber-200',
accentColor: 'border-l-amber-500',
textColor: 'text-amber-900',
titleColor: 'text-amber-700',
name: 'System'
};
default:
return {
icon: <Bot className="w-5 h-5 text-gray-600" />,
bgColor: 'bg-gray-50',
borderColor: 'border-gray-200',
accentColor: 'border-l-gray-500',
textColor: 'text-gray-900',
titleColor: 'text-gray-700',
name: 'Unknown'
};
}
};
const roleConfig = getRoleConfig();
// Helper function to check if content is a system reminder
const isSystemReminder = (text: string) => {
return text.includes('<system-reminder>') || text.includes('</system-reminder>');
};
// Helper function to extract non-system-reminder content for preview
const extractNonSystemContent = (content: string) => {
// Split by system-reminder tags and filter out the reminder parts
const parts = content.split(/<system-reminder>[\s\S]*?<\/system-reminder>/g);
return parts.filter(part => part.trim()).join(' ').trim();
};
// Determine if content should be expandable
const getContentPreview = () => {
if (typeof message.content === 'string') {
const nonSystemContent = extractNonSystemContent(message.content);
if (!nonSystemContent && isSystemReminder(message.content)) {
return "[System reminder]";
}
return nonSystemContent.length > 300 ? nonSystemContent.substring(0, 300) + '...' : nonSystemContent;
}
if (Array.isArray(message.content)) {
const allText = message.content
.filter(c => c.type === 'text' && c.text)
.map(c => {
const nonSystemContent = extractNonSystemContent(c.text);
return nonSystemContent;
})
.filter(text => text)
.join('\\n');
if (!allText) {
const hasToolUse = message.content.some(c => c.type === 'tool_use');
const hasSystemReminder = message.content.some(c => c.type === 'text' && c.text && isSystemReminder(c.text));
if (hasToolUse) return "[Tool call]";
if (hasSystemReminder) return "[System reminder]";
return "[Context message]";
}
return allText.length > 300 ? allText.substring(0, 300) + '...' : allText;
}
if (message.content?.type) {
return `[${message.content.type.replace('_', ' ')}]`;
}
try {
const str = JSON.stringify(message.content, null, 2);
return str.length > 300 ? str.substring(0, 300) + '...' : str;
} catch {
return '[Complex content]';
}
};
const shouldShowExpander = () => {
if (typeof message.content === 'string') {
// Show expander if content is long OR contains system reminders
return message.content.length > 300 || isSystemReminder(message.content);
}
if (Array.isArray(message.content)) {
const allText = message.content
.filter(c => c.type === 'text' && c.text)
.map(c => c.text)
.join('\\n');
return allText.length > 300 || message.content.length > 1;
}
return true;
};
const formatTimestamp = (timestamp: string) => {
try {
const date = new Date(timestamp);
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return timestamp;
}
};
return (
<div className="relative">
{/* Connection line to next message */}
{!isLast && (
<div className="absolute left-5 top-16 w-0.5 h-8 bg-gray-200"></div>
)}
{/* Message container */}
<div className={`relative ${message.isNewInTurn ? 'animate-in slide-in-from-left-2' : ''}`}>
{/* New message indicator */}
{message.isNewInTurn && (
<div className="absolute -left-2 top-0 w-1 h-full bg-gradient-to-b from-blue-500 to-transparent rounded-full opacity-60"></div>
)}
<div className={`
${roleConfig.bgColor}
${roleConfig.borderColor}
${roleConfig.accentColor}
border border-l-4 rounded-xl p-5
${message.isNewInTurn ? 'ring-2 ring-blue-200/30 shadow-md' : 'shadow-sm'}
transition-all duration-200 hover:shadow-md
`}>
<div className="flex items-start space-x-4">
{/* Avatar */}
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center border-2 border-gray-200 shadow-sm">
{roleConfig.icon}
</div>
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<span className={`font-semibold text-lg ${roleConfig.titleColor}`}>
{roleConfig.name}
</span>
{message.isNewInTurn && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200 font-medium">
NEW
</span>
)}
<span className="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">
#{index + 1}
</span>
{message.turnNumber && (
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full border border-purple-200">
Turn {message.turnNumber}
</span>
)}
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-1 text-xs text-gray-500">
<Clock className="w-3 h-3" />
<span>{formatTimestamp(message.timestamp)}</span>
</div>
</div>
</div>
{/* Content */}
<div className="space-y-4">
{shouldShowExpander() && !isExpanded ? (
<div className="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
<div className="text-sm text-gray-700 leading-relaxed">
{typeof message.content === 'string' ? (
<div dangerouslySetInnerHTML={{ __html: formatLargeText(getContentPreview()) }} />
) : (
<div className="space-y-2">
<div className="text-gray-600 font-medium">
{Array.isArray(message.content) ? (
`Message contains ${message.content.length} content blocks`
) : (
'Complex content'
)}
</div>
{Array.isArray(message.content) && (
<div className="text-xs text-gray-500 pl-2 border-l-2 border-gray-200">
{message.content.map(item => item.type).join(' → ')}
</div>
)}
<div className="text-xs text-gray-500 mt-1 italic">
{getContentPreview()}
</div>
</div>
)}
</div>
<button
onClick={() => setIsExpanded(true)}
className="mt-3 flex items-center space-x-2 text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
<ChevronRight className="w-4 h-4" />
<span>Show full content</span>
</button>
</div>
) : (
<div className="bg-white rounded-lg p-4 border border-gray-200 shadow-sm">
{shouldShowExpander() && isExpanded && (
<div className="mb-3 pb-3 border-b border-gray-200">
<button
onClick={() => setIsExpanded(false)}
className="flex items-center space-x-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
<ChevronDown className="w-4 h-4" />
<span>Collapse</span>
</button>
</div>
)}
<MessageContent content={message.content} />
</div>
)}
</div>
</div>
</div>
</div>
{/* Flow indicator */}
{!isLast && (
<div className="flex items-center justify-center py-2">
<ArrowDown className="w-4 h-4 text-gray-400" />
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,789 @@
import { useState } from 'react';
import {
ChevronDown,
Info,
Settings,
Cpu,
MessageCircle,
Brain,
User,
Bot,
Target,
Copy,
Check,
ArrowLeftRight,
Activity,
Clock,
Wifi,
Calendar,
List,
FileText
} from 'lucide-react';
import { MessageContent } from './MessageContent';
import { formatJSON } from '../utils/formatters';
interface Request {
id: number;
timestamp: string;
method: string;
endpoint: string;
headers: Record<string, string[]>;
body?: {
model?: string;
messages?: Array<{
role: string;
content: any;
}>;
system?: Array<{
text: string;
type: string;
cache_control?: { type: string };
}>;
max_tokens?: number;
temperature?: number;
stream?: boolean;
};
response?: {
statusCode: number;
headers: Record<string, string[]>;
body?: any;
bodyText?: string;
responseTime: number;
streamingChunks?: string[];
isStreaming: boolean;
completedAt: string;
};
promptGrade?: {
score: number;
criteria: Record<string, { score: number; feedback: string }>;
feedback: string;
improvedPrompt: string;
gradingTimestamp: string;
};
}
interface RequestDetailContentProps {
request: Request;
onGrade: () => void;
}
export default function RequestDetailContent({ request, onGrade }: RequestDetailContentProps) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
overview: true,
conversation: true
});
const [copied, setCopied] = useState<Record<string, boolean>>({});
const toggleSection = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleCopy = async (content: string, key: string) => {
try {
await navigator.clipboard.writeText(content);
setCopied(prev => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopied(prev => ({ ...prev, [key]: false }));
}, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const getMethodColor = (method: string) => {
const colors = {
'GET': 'bg-green-50 text-green-700 border border-green-200',
'POST': 'bg-blue-50 text-blue-700 border border-blue-200',
'PUT': 'bg-yellow-50 text-yellow-700 border border-yellow-200',
'DELETE': 'bg-red-50 text-red-700 border border-red-200'
};
return colors[method as keyof typeof colors] || 'bg-gray-50 text-gray-700 border border-gray-200';
};
const canGradeRequest = (request: Request) => {
return request.body &&
request.body.messages &&
request.body.messages.some(msg => msg.role === 'user') &&
request.endpoint.includes('/messages');
};
return (
<div className="space-y-6">
{/* Request Overview */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Info className="w-5 h-5 text-blue-600" />
<span>Request Overview</span>
</h4>
{/* {!request.promptGrade && canGradeRequest(request) && (
<button
onClick={onGrade}
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<Target className="w-4 h-4" />
<span>Grade This Prompt</span>
</button>
)} */}
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Method:</span>
<span className={`px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wide ${getMethodColor(request.method)}`}>
{request.method}
</span>
</div>
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Endpoint:</span>
<code className="text-blue-600 bg-blue-50 px-2 py-1 rounded font-mono text-xs border border-blue-200">
{request.endpoint}
</code>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">Timestamp:</span>
<span className="text-gray-900">{new Date(request.timestamp).toLocaleString()}</span>
</div>
<div className="flex items-center space-x-3">
<span className="text-gray-500 font-medium min-w-[80px]">User Agent:</span>
<span className="text-gray-600 text-xs">{request.headers['User-Agent']?.[0] || 'N/A'}</span>
</div>
</div>
</div>
</div>
{/* Headers */}
<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 cursor-pointer"
onClick={() => toggleSection('headers')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Settings className="w-5 h-5 text-blue-600" />
<span>Request Headers</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.headers ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.headers && (
<div className="p-6">
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Headers</span>
<button
onClick={() => handleCopy(formatJSON(request.headers), 'headers')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy headers"
>
{copied.headers ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-sm text-gray-700 overflow-x-auto">
{formatJSON(request.headers)}
</pre>
</div>
</div>
)}
</div>
{request.body && (
<>
{/* System Messages */}
{request.body.system && (
<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 cursor-pointer"
onClick={() => toggleSection('system')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Cpu className="w-5 h-5 text-yellow-600" />
<span>System Instructions</span>
<span className="text-xs bg-yellow-50 text-yellow-700 px-2 py-1 rounded-full border border-yellow-200">
{request.body.system.length} items
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.system ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.system && (
<div className="p-6 space-y-4">
{request.body.system.map((sys, index) => (
<div key={index} className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-yellow-700 font-medium text-sm">System Message #{index + 1}</span>
{sys.cache_control && (
<span className="text-xs bg-orange-100 text-orange-700 px-2 py-1 rounded-full border border-orange-200">
Cache: {sys.cache_control.type}
</span>
)}
</div>
<div className="bg-white rounded p-3 border border-gray-200">
<MessageContent content={{ type: 'text', text: sys.text }} />
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Conversation */}
{request.body.messages && (
<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 cursor-pointer"
onClick={() => toggleSection('conversation')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<MessageCircle className="w-5 h-5 text-blue-600" />
<span>Conversation</span>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{request.body.messages.length} messages
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.conversation ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.conversation && (
<div className="p-6 space-y-4 max-h-[600px] overflow-y-auto">
{request.body.messages.map((message, index) => (
<MessageBubble key={index} message={message} index={index} />
))}
</div>
)}
</div>
)}
{/* Model Configuration */}
<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 cursor-pointer"
onClick={() => toggleSection('model')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<Brain className="w-5 h-5 text-purple-600" />
<span>Model Configuration</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.model ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.model && (
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Model</div>
<div className="text-sm font-medium text-gray-900">{request.body.model || 'N/A'}</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Max Tokens</div>
<div className="text-sm font-medium text-gray-900">
{request.body.max_tokens?.toLocaleString() || 'N/A'}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Temperature</div>
<div className="text-sm font-medium text-gray-900">{request.body.temperature ?? 'N/A'}</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-xs text-gray-500 mb-1">Stream</div>
<div className="text-sm font-medium text-gray-900">
{request.body.stream ? '✅ Yes' : '❌ No'}
</div>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* API Response */}
{request.response && (
<ResponseDetails response={request.response} />
)}
{/* Prompt Grading Results */}
{request.promptGrade && (
<PromptGradingResults promptGrade={request.promptGrade} />
)}
</div>
);
}
// Message bubble component
function MessageBubble({ message, index }: { message: any; index: number }) {
const roleColors = {
'user': 'bg-blue-50 border border-blue-200',
'assistant': 'bg-gray-50 border border-gray-200',
'system': 'bg-yellow-50 border border-yellow-200'
};
const roleIcons = {
'user': User,
'assistant': Bot,
'system': Settings
};
const roleIconColors = {
'user': 'text-blue-600',
'assistant': 'text-gray-600',
'system': 'text-yellow-600'
};
const Icon = roleIcons[message.role as keyof typeof roleIcons] || User;
return (
<div className={`rounded-lg p-4 ${roleColors[message.role as keyof typeof roleColors] || 'bg-gray-50 border border-gray-200'}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center border border-gray-200">
<Icon className={`w-4 h-4 ${roleIconColors[message.role as keyof typeof roleIconColors] || 'text-gray-600'}`} />
</div>
<span className="font-medium capitalize text-gray-900">{message.role}</span>
<span className="text-xs text-gray-500 bg-white px-2 py-1 rounded-full border border-gray-200">
#{index + 1}
</span>
</div>
</div>
<div>
<MessageContent content={message.content} />
</div>
</div>
);
}
// Placeholder for prompt grading results - you can expand this
function PromptGradingResults({ promptGrade }: { promptGrade: any }) {
return (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Prompt Quality Analysis</h4>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-gray-700">Overall Score:</span>
<span className="text-2xl font-bold text-blue-600">{promptGrade.score}/5</span>
</div>
<div className="text-sm text-gray-600">
<p>{promptGrade.feedback}</p>
</div>
</div>
</div>
);
}
// Response Details Component
function ResponseDetails({ response }: { response: NonNullable<Request['response']> }) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
overview: true
});
const [copied, setCopied] = useState<Record<string, boolean>>({});
const toggleSection = (section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleCopy = async (content: string, key: string) => {
try {
await navigator.clipboard.writeText(content);
setCopied(prev => ({ ...prev, [key]: true }));
setTimeout(() => {
setCopied(prev => ({ ...prev, [key]: false }));
}, 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const getStatusColor = (statusCode: number) => {
if (statusCode >= 200 && statusCode < 300) {
return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-600' };
}
if (statusCode >= 400 && statusCode < 500) {
return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-600' };
}
if (statusCode >= 500) {
return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-600' };
}
return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-600' };
};
// Parse streaming chunks to extract the final assembled text
const parseStreamingResponse = (chunks: string[]) => {
let assembledText = '';
let rawData = chunks.join('');
try {
// Split by lines and process each SSE event
const lines = rawData.split('\n').filter(line => line.trim());
for (const line of lines) {
// Look for data lines in SSE format
if (line.startsWith('data: ')) {
const jsonStr = line.substring(6).trim();
// Skip non-JSON lines (like "data: [DONE]")
if (!jsonStr.startsWith('{')) continue;
try {
const eventData = JSON.parse(jsonStr);
// Extract text from content_block_delta events
if (eventData.type === 'content_block_delta' &&
eventData.delta &&
eventData.delta.type === 'text_delta' &&
typeof eventData.delta.text === 'string') {
assembledText += eventData.delta.text;
}
} catch (parseError) {
// Skip malformed JSON
continue;
}
}
}
// If we successfully extracted text, return it
if (assembledText.trim().length > 0) {
return {
finalText: assembledText,
isFormatted: true,
rawData: rawData
};
}
// Fallback: try to find any text content in the raw data
const textMatches = rawData.match(/"text":"([^"]+)"/g);
if (textMatches) {
let fallbackText = '';
for (const match of textMatches) {
const text = match.match(/"text":"([^"]+)"/)?.[1];
if (text) {
// Unescape common JSON escape sequences
fallbackText += text.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
}
}
if (fallbackText.trim()) {
return {
finalText: fallbackText,
isFormatted: true,
rawData: rawData
};
}
}
} catch (error) {
console.warn('Error parsing streaming response:', error);
}
// Ultimate fallback to raw concatenation
return {
finalText: rawData,
isFormatted: false,
rawData: rawData
};
};
const statusColors = getStatusColor(response.statusCode);
const completedAt = response.completedAt ? new Date(response.completedAt).toLocaleString() : 'Unknown';
return (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm border-l-4 border-l-blue-500">
<div
className="bg-gray-50 px-6 py-4 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('overview')}
>
<div className="flex items-center justify-between">
<h4 className="text-lg font-semibold text-gray-900 flex items-center space-x-3">
<ArrowLeftRight className="w-5 h-5 text-blue-600" />
<span>API Response</span>
<span className={`text-xs px-2 py-1 rounded-full border ${statusColors.bg} ${statusColors.text} ${statusColors.border}`}>
{response.statusCode}
</span>
</h4>
<ChevronDown className={`w-5 h-5 text-gray-500 transition-transform ${
expandedSections.overview ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.overview && (
<div className="p-6 space-y-6">
{/* Response Overview */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className={`${statusColors.bg} border ${statusColors.border} rounded-lg p-4`}>
<div className="flex items-center space-x-2 mb-2">
<Activity className={`w-4 h-4 ${statusColors.icon}`} />
<span className={`text-xs font-medium ${statusColors.text}`}>Status</span>
</div>
<div className={`text-lg font-bold ${statusColors.text}`}>{response.statusCode}</div>
<div className={`text-xs ${statusColors.text} opacity-75`}>
{response.statusCode >= 200 && response.statusCode < 300 ? 'Success' :
response.statusCode >= 400 && response.statusCode < 500 ? 'Client Error' :
response.statusCode >= 500 ? 'Server Error' : 'Unknown'}
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Clock className="w-4 h-4 text-blue-600" />
<span className="text-xs font-medium text-blue-700">Response Time</span>
</div>
<div className="text-lg font-bold text-blue-700">{response.responseTime}ms</div>
<div className="text-xs text-blue-700 opacity-75">
{response.responseTime < 1000 ? 'Fast' : response.responseTime < 3000 ? 'Normal' : 'Slow'}
</div>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Wifi className="w-4 h-4 text-purple-600" />
<span className="text-xs font-medium text-purple-700">Type</span>
</div>
<div className="text-lg font-bold text-purple-700">
{response.isStreaming ? 'Stream' : 'Single'}
</div>
<div className="text-xs text-purple-700 opacity-75">
{response.isStreaming ? 'Streaming' : 'Complete'}
</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center space-x-2 mb-2">
<Calendar className="w-4 h-4 text-gray-600" />
<span className="text-xs font-medium text-gray-700">Completed</span>
</div>
<div className="text-sm font-bold text-gray-700">{completedAt.split(' ')[1] || 'N/A'}</div>
<div className="text-xs text-gray-700 opacity-75">{completedAt.split(' ')[0] || ''}</div>
</div>
</div>
{/* Response Headers */}
{response.headers && (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('responseHeaders')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<List className="w-4 h-4 text-gray-600" />
<span>Response Headers</span>
<span className="text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded-full">
{Object.keys(response.headers).length}
</span>
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.responseHeaders ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.responseHeaders && (
<div className="px-4 pb-4">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Headers</span>
<button
onClick={() => handleCopy(formatJSON(response.headers), 'responseHeaders')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy response headers"
>
{copied.responseHeaders ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-xs text-gray-700 overflow-x-auto">
{formatJSON(response.headers)}
</pre>
</div>
</div>
)}
</div>
)}
{/* Response Body */}
{(response.body || response.bodyText) && (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('responseBody')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<FileText className="w-4 h-4 text-gray-600" />
<span>Response Body</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{response.body ? 'JSON' : 'Text'}
</span>
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.responseBody ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.responseBody && (
<div className="px-4 pb-4">
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Response</span>
<button
onClick={() => handleCopy(
response.body ? formatJSON(response.body) : (response.bodyText || ''),
'responseBody'
)}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy response body"
>
{copied.responseBody ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<pre className="text-xs text-gray-700 overflow-x-auto max-h-96 overflow-y-auto">
{response.body ? formatJSON(response.body) : response.bodyText}
</pre>
</div>
</div>
)}
</div>
)}
{/* Streaming Response */}
{response.isStreaming && response.streamingChunks && response.streamingChunks.length > 0 && (() => {
const parsed = parseStreamingResponse(response.streamingChunks);
return (
<div className="bg-gray-50 border border-gray-200 rounded-xl overflow-hidden">
<div
className="px-4 py-3 border-b border-gray-200 cursor-pointer"
onClick={() => toggleSection('streamingResponse')}
>
<div className="flex items-center justify-between">
<h5 className="text-sm font-semibold text-gray-900 flex items-center space-x-2">
<Wifi className="w-4 h-4 text-gray-600" />
<span>Streaming Response</span>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{response.streamingChunks.length} chunks
</span>
{parsed.isFormatted && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded-full border border-green-200">
Parsed
</span>
)}
</h5>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.streamingResponse ? 'rotate-180' : ''
}`} />
</div>
</div>
{expandedSections.streamingResponse && (
<div className="px-4 pb-4 space-y-3">
{/* Clean Parsed Response */}
{parsed.isFormatted && (
<div className="bg-white rounded-lg p-4 border border-green-200">
<div className="flex items-center justify-between mb-3">
<h6 className="text-sm font-semibold text-green-900 flex items-center space-x-2">
<Check className="w-4 h-4" />
<span>Final Response (Clean)</span>
</h6>
<button
onClick={() => handleCopy(parsed.finalText, 'streamingClean')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy clean response"
>
{copied.streamingClean ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
<div className="bg-gray-50 rounded p-3 border border-gray-200">
<pre className="text-sm text-gray-900 whitespace-pre-wrap leading-relaxed">
{parsed.finalText}
</pre>
</div>
<div className="mt-2 text-xs text-green-600">
Extracted clean text from streaming chunks
</div>
</div>
)}
{/* Raw Data (Collapsible) */}
<div className="bg-gray-50 rounded-lg border border-gray-200">
<div
className="px-3 py-2 cursor-pointer flex items-center justify-between"
onClick={() => toggleSection('rawStreamingData')}
>
<span className="text-sm font-medium text-gray-700 flex items-center space-x-2">
<FileText className="w-4 h-4" />
<span>Raw Streaming Data</span>
</span>
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
expandedSections.rawStreamingData ? 'rotate-180' : ''
}`} />
</div>
{expandedSections.rawStreamingData && (
<div className="px-3 pb-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-600">SSE Events & Metadata</span>
<button
onClick={() => handleCopy(parsed.rawData, 'streamingRaw')}
className="p-1 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy raw data"
>
{copied.streamingRaw ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</div>
<pre className="text-xs text-gray-600 overflow-x-auto max-h-64 overflow-y-auto bg-gray-100 rounded p-2 font-mono">
{parsed.rawData}
</pre>
</div>
)}
</div>
<div className="text-xs text-gray-500">
{parsed.isFormatted
? `Successfully parsed ${response.streamingChunks.length} streaming chunks`
: `Raw display of ${response.streamingChunks.length} streaming chunks (parsing failed)`
}
</div>
</div>
)}
</div>
);
})()}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,195 @@
import { CheckSquare, Square, Clock, AlertCircle, ListTodo } from 'lucide-react';
interface Todo {
task?: string;
description?: string;
content?: string;
title?: string;
text?: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
[key: string]: any; // Allow other properties
}
interface TodoListProps {
todos: Todo[];
}
export function TodoList({ todos }: TodoListProps) {
// Debug: Log the structure of the first todo
if (todos && todos.length > 0) {
console.log('Todo structure:', todos[0]);
}
if (!todos || todos.length === 0) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<ListTodo className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600">No tasks in the todo list</p>
</div>
);
}
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'text-red-600 bg-red-50 border-red-200';
case 'medium':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low':
return 'text-green-600 bg-green-50 border-green-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckSquare className="w-4 h-4 text-green-600" />;
case 'in_progress':
return <Clock className="w-4 h-4 text-blue-600 animate-pulse" />;
case 'pending':
return <Square className="w-4 h-4 text-gray-400" />;
default:
return <AlertCircle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-50 border-green-200';
case 'in_progress':
return 'bg-blue-50 border-blue-200';
case 'pending':
return 'bg-gray-50 border-gray-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
// Group todos by status
const groupedTodos = {
in_progress: todos.filter(t => t.status === 'in_progress'),
pending: todos.filter(t => t.status === 'pending'),
completed: todos.filter(t => t.status === 'completed')
};
return (
<div className="space-y-3">
{/* Summary stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<ListTodo className="w-4 h-4 text-indigo-600" />
<span className="text-sm font-semibold text-gray-900">Todo List</span>
</div>
<div className="flex items-center space-x-2 text-xs">
{groupedTodos.in_progress.length > 0 && (
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded-full border border-blue-200">
{groupedTodos.in_progress.length} in progress
</span>
)}
{groupedTodos.pending.length > 0 && (
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full border border-gray-200">
{groupedTodos.pending.length} pending
</span>
)}
{groupedTodos.completed.length > 0 && (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full border border-green-200">
{groupedTodos.completed.length} completed
</span>
)}
</div>
</div>
{/* Todo items */}
<div className="space-y-2">
{/* In Progress items first */}
{groupedTodos.in_progress.map((todo, index) => (
<TodoItem key={`in-progress-${index}`} todo={todo} />
))}
{/* Pending items */}
{groupedTodos.pending.map((todo, index) => (
<TodoItem key={`pending-${index}`} todo={todo} />
))}
{/* Completed items last */}
{groupedTodos.completed.map((todo, index) => (
<TodoItem key={`completed-${index}`} todo={todo} />
))}
</div>
</div>
);
}
function TodoItem({ todo }: { todo: Todo }) {
// Get the task text from various possible property names
const getTaskText = (todo: Todo): string => {
return todo.task || todo.description || todo.content || todo.title || todo.text ||
Object.entries(todo).find(([key, value]) =>
typeof value === 'string' &&
!['priority', 'status'].includes(key)
)?.[1] ||
'No task description';
};
const taskText = getTaskText(todo);
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'text-red-600 bg-red-50 border-red-200';
case 'medium':
return 'text-yellow-600 bg-yellow-50 border-yellow-200';
case 'low':
return 'text-green-600 bg-green-50 border-green-200';
default:
return 'text-gray-600 bg-gray-50 border-gray-200';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckSquare className="w-4 h-4 text-green-600" />;
case 'in_progress':
return <Clock className="w-4 h-4 text-blue-600 animate-pulse" />;
case 'pending':
return <Square className="w-4 h-4 text-gray-400" />;
default:
return <AlertCircle className="w-4 h-4 text-gray-400" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'bg-green-50 border-green-200';
case 'in_progress':
return 'bg-blue-50 border-blue-200';
case 'pending':
return 'bg-gray-50 border-gray-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
return (
<div className={`flex items-start space-x-3 p-3 rounded-lg border ${getStatusColor(todo.status)} transition-all duration-200`}>
<div className="flex-shrink-0 mt-0.5">
{getStatusIcon(todo.status)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${todo.status === 'completed' ? 'line-through text-gray-500' : 'text-gray-900'}`}>
{taskText}
</p>
</div>
<div className="flex-shrink-0">
<span className={`text-xs px-2 py-1 rounded-full border font-medium ${getPriorityColor(todo.priority)}`}>
{todo.priority}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,257 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, CheckCircle, AlertCircle, FileText, Database, Clock } from 'lucide-react';
import { formatValue, formatJSON, isComplexObject, truncateText } from '../utils/formatters';
import { CodeViewer } from './CodeViewer';
interface ToolResultProps {
content: any;
toolId?: string;
isError?: boolean;
}
export function ToolResult({ content, toolId, isError = false }: ToolResultProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Detect if this is likely code content from a Read tool
const isCodeContent = (content: string): boolean => {
if (typeof content !== 'string') return false;
// Check for line numbers pattern (e.g., " 1→" from cat -n output)
const hasLineNumbers = /^\s*\d+→/m.test(content);
// Check for common code patterns
const hasCodePatterns = (
content.includes('function') ||
content.includes('const ') ||
content.includes('let ') ||
content.includes('var ') ||
content.includes('import ') ||
content.includes('export ') ||
content.includes('class ') ||
content.includes('interface ') ||
content.includes('type ') ||
content.includes('def ') ||
content.includes('if (') ||
content.includes('for (') ||
content.includes('while (') ||
content.includes('{') && content.includes('}')
);
// Check for file extension indicators in the content
const hasFileExtension = /\.(js|jsx|ts|tsx|py|rb|go|rs|java|cpp|c|h|cs|php|swift|kt|scala|r|sh|bash|sql|html|css|json|yaml|yml|toml|md|xml)$/m.test(content);
return hasLineNumbers || (hasCodePatterns && content.length > 100);
};
// Extract code from cat -n format if present
const extractCodeFromCatN = (content: string): { code: string; fileName?: string } => {
if (typeof content !== 'string') return { code: content };
// Check if this is cat -n output
if (!/^\s*\d+→/m.test(content)) {
return { code: content };
}
// Extract the code by removing line numbers
const lines = content.split('\n');
const codeLines = lines.map(line => {
// Match line number pattern and extract the code part
const match = line.match(/^\s*\d+→(.*)$/);
return match ? match[1] : line;
});
return { code: codeLines.join('\n') };
};
// Handle different content structures
const getDisplayContent = () => {
// If content is a string, return it directly
if (typeof content === 'string') {
return content;
}
// If content has a 'text' property, use that
if (content && typeof content === 'object' && 'text' in content) {
return content.text;
}
// If content has a 'content' property, use that
if (content && typeof content === 'object' && 'content' in content) {
return content.content;
}
// If it's an array, join with newlines
if (Array.isArray(content)) {
return content.map(item => formatValue(item)).join('\n');
}
// For complex objects, show JSON
if (isComplexObject(content)) {
return formatJSON(content);
}
// Fallback to string conversion
return formatValue(content);
};
const displayContent = getDisplayContent();
const isLargeContent = displayContent.length > 500;
const shouldTruncate = isLargeContent && !isExpanded;
const truncatedContent = shouldTruncate ? truncateText(displayContent, 500) : displayContent;
// Determine if content should be rendered as JSON
const isJSONContent = isComplexObject(content) || (typeof content === 'string' && content.startsWith('{'));
// Check if this is code content
const isCode = isCodeContent(displayContent);
const { code: extractedCode } = isCode ? extractCodeFromCatN(displayContent) : { code: displayContent };
const getResultConfig = () => {
if (isError) {
return {
bgColor: 'bg-gradient-to-r from-red-50 to-pink-50',
borderColor: 'border-red-200',
accentColor: 'border-l-red-500',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
titleColor: 'text-red-900',
icon: <AlertCircle className="w-5 h-5" />,
title: 'Tool Error'
};
}
return {
bgColor: 'bg-gradient-to-r from-emerald-50 to-green-50',
borderColor: 'border-emerald-200',
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-100',
iconColor: 'text-emerald-600',
titleColor: 'text-emerald-900',
icon: <CheckCircle className="w-5 h-5" />,
title: 'Tool Result'
};
};
const config = getResultConfig();
return (
<div className={`${config.bgColor} ${config.borderColor} ${config.accentColor} border border-l-4 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200`}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 ${config.iconBg} rounded-xl flex items-center justify-center shadow-sm`}>
<div className={config.iconColor}>
{config.icon}
</div>
</div>
<div>
<div className="flex items-center space-x-2">
<span className={`font-semibold text-base ${config.titleColor}`}>
{config.title}
</span>
<Database className="w-4 h-4 text-gray-500" />
</div>
{toolId && (
<div className="flex items-center space-x-2 mt-1">
<FileText className="w-3 h-3 text-gray-500" />
<span className="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">
{toolId}
</span>
</div>
)}
</div>
</div>
{isLargeContent && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center space-x-2 text-xs text-gray-600 hover:text-gray-800 bg-white hover:bg-gray-50 px-3 py-2 rounded-lg border border-gray-200 transition-all duration-200"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{isExpanded ? 'Collapse' : 'Expand'}</span>
</button>
)}
</div>
{/* Content */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="p-4">
{/* Content type indicator */}
<div className="flex items-center justify-between mb-3 pb-2 border-b border-gray-100">
<div className="flex items-center space-x-2 text-xs text-gray-600">
<Clock className="w-3 h-3" />
<span>Result received</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{isCode ? 'Code' : isJSONContent ? 'JSON' : 'Text'}
</span>
{!isCode && (
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{displayContent.length} chars
</span>
)}
</div>
</div>
{/* Main content */}
{isCode ? (
<CodeViewer code={extractedCode} fileName={content.fileName} />
) : isJSONContent ? (
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono overflow-x-auto bg-gray-50 rounded-lg p-3 border border-gray-200">
{truncatedContent}
</pre>
) : (
<div
className="text-sm text-gray-700 whitespace-pre-wrap break-words leading-relaxed"
dangerouslySetInnerHTML={{
__html: truncatedContent.replace(/\n/g, '<br>')
}}
/>
)}
{/* Expand/collapse controls */}
{shouldTruncate && !isCode && (
<div className="mt-3 pt-3 border-t border-gray-200">
<button
onClick={() => setIsExpanded(true)}
className="text-xs text-blue-600 hover:text-blue-800 underline transition-colors"
>
Show full content ({displayContent.length.toLocaleString()} characters)
</button>
</div>
)}
</div>
</div>
{/* Metadata */}
{content && typeof content === 'object' && Object.keys(content).length > 1 && (
<div className="mt-3">
<details className="cursor-pointer group">
<summary className="text-xs text-gray-600 hover:text-gray-800 transition-colors flex items-center space-x-1">
<ChevronRight className="w-3 h-3 group-open:rotate-90 transition-transform" />
<span>Show raw data structure</span>
</summary>
<div className="mt-2 bg-white rounded-lg border border-gray-200 p-3">
<pre className="text-xs overflow-x-auto font-mono text-gray-700 bg-gray-50 rounded p-2">
{formatJSON(content)}
</pre>
</div>
</details>
</div>
)}
{/* Result indicator */}
<div className="mt-4 pt-3 border-t border-gray-200">
<div className={`flex items-center space-x-2 text-xs ${config.titleColor}`}>
<div className={`w-2 h-2 rounded-full ${isError ? 'bg-red-500' : 'bg-emerald-500'}`}></div>
<span>{isError ? 'Execution failed' : 'Execution completed'}</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,209 @@
import { useState } from 'react';
import { Wrench, ChevronDown, ChevronRight, Copy, Check, Terminal, Zap } from 'lucide-react';
import { formatValue, formatJSON, isComplexObject } from '../utils/formatters';
import { CodeDiff } from './CodeDiff';
import { TodoList } from './TodoList';
interface ToolUseProps {
name: string;
id: string;
input?: Record<string, any>;
text?: string;
}
export function ToolUse({ name, id, input = {}, text }: ToolUseProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(formatJSON({ name, id, input }));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy to clipboard:', error);
}
};
const renderParameterValue = (value: any) => {
if (typeof value === 'string') {
if (value.length > 200 || value.includes('\n')) {
return (
<div>
<button
className="text-xs text-indigo-600 hover:text-indigo-800 underline mb-2 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Hide' : 'Show'} large parameter
</button>
{isExpanded && (
<pre className="bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs max-h-64 overflow-auto font-mono">
{value}
</pre>
)}
</div>
);
}
return <span className="text-gray-700 text-sm break-all font-mono">{value}</span>;
}
if (isComplexObject(value)) {
return (
<details className="cursor-pointer">
<summary className="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors">
Show object ({Object.keys(value).length} properties)
</summary>
<pre className="mt-2 bg-gray-50 border border-gray-200 p-3 rounded-lg text-xs overflow-auto font-mono">
{formatJSON(value)}
</pre>
</details>
);
}
return <span className="text-gray-700 text-sm font-mono">{formatValue(value)}</span>;
};
return (
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 border border-indigo-200 rounded-xl p-5 shadow-sm hover:shadow-md transition-all duration-200">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-indigo-500 to-blue-600 rounded-xl flex items-center justify-center shadow-sm">
<Wrench className="w-5 h-5 text-white" />
</div>
<div>
<div className="flex items-center space-x-2">
<span className="text-indigo-900 font-semibold text-base">Tool Execution</span>
<Zap className="w-4 h-4 text-indigo-600" />
</div>
<div className="flex items-center space-x-2 mt-1">
<Terminal className="w-3 h-3 text-indigo-600" />
<span className="font-mono text-sm text-indigo-700 bg-white px-2 py-1 rounded-md border border-indigo-200 font-medium">
{name}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 font-mono bg-white px-2 py-1 rounded-md border border-gray-200">
{id}
</span>
<button
onClick={handleCopy}
className="p-2 text-gray-500 hover:text-indigo-600 hover:bg-white transition-all duration-200 rounded-lg border border-transparent hover:border-indigo-200"
title="Copy tool call details"
>
{copied ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Special handling for Edit tool - show code diff */}
{name === 'Edit' && input.old_string && input.new_string && (
<div className="mb-4">
<div className="text-sm font-semibold text-indigo-900 mb-3">Code Changes</div>
<CodeDiff
oldCode={input.old_string as string}
newCode={input.new_string as string}
fileName={input.file_path as string}
/>
</div>
)}
{/* Special handling for Read tool - show code with syntax highlighting */}
{name === 'Read' && input.file_path && (
<div className="mb-4">
<div className="text-sm font-semibold text-indigo-900 mb-3">File Contents</div>
{/* Note: The actual file content will be in the tool result, not the input */}
<div className="text-xs text-gray-600 mb-2">
Reading: <span className="font-mono">{input.file_path}</span>
</div>
</div>
)}
{/* Special handling for TodoWrite tool - show todo list */}
{name === 'TodoWrite' && input.todos && Array.isArray(input.todos) && (
<div className="mb-4">
<div className="text-sm font-semibold text-indigo-900 mb-3">Task Management</div>
<TodoList todos={input.todos} />
</div>
)}
{/* Parameters */}
{Object.keys(input).length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-indigo-900 flex items-center space-x-2">
<span>Parameters</span>
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-1 rounded-full border border-indigo-200">
{Object.keys(input).length}
</span>
</div>
{Object.keys(input).length > 2 && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center space-x-1 text-xs text-indigo-600 hover:text-indigo-800 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<span>{isExpanded ? 'Collapse' : 'Expand'}</span>
</button>
)}
</div>
{/* Don't show raw parameters for Edit and TodoWrite tools since we have custom views */}
{name !== 'Edit' && name !== 'TodoWrite' && (
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className={`space-y-3 ${!isExpanded && Object.keys(input).length > 2 ? 'max-h-32 overflow-hidden' : ''}`}>
{Object.entries(input).map(([key, value]) => (
<div key={key} className="flex items-start space-x-3 p-3 bg-gray-50 rounded-lg border border-gray-100">
<span className="font-mono text-sm text-indigo-600 pt-0.5 min-w-0 flex-shrink-0 font-medium">
{key}:
</span>
<div className="flex-1 min-w-0">
{renderParameterValue(value)}
</div>
</div>
))}
</div>
{!isExpanded && Object.keys(input).length > 2 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<button
onClick={() => setIsExpanded(true)}
className="text-xs text-indigo-600 hover:text-indigo-800 underline transition-colors"
>
Show all {Object.keys(input).length} parameters
</button>
</div>
)}
</div>
)}
</div>
)}
{/* Additional text */}
{text && (
<div className="bg-white rounded-lg p-3 border border-gray-200 shadow-sm">
<div className="text-xs text-gray-600 mb-1 font-medium">Additional Information:</div>
<div className="text-sm text-gray-700">{text}</div>
</div>
)}
{/* Tool execution indicator */}
<div className="mt-4 pt-3 border-t border-indigo-200">
<div className="flex items-center space-x-2 text-xs text-indigo-700">
<div className="w-2 h-2 bg-indigo-500 rounded-full animate-pulse"></div>
<span>Tool execution initiated</span>
</div>
</div>
</div>
);
}

18
web/app/entry.client.tsx Normal file
View file

@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

140
web/app/entry.server.tsx Normal file
View file

@ -0,0 +1,140 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal`
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

45
web/app/root.tsx Normal file
View file

@ -0,0 +1,45 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import "./tailwind.css";
export const links: LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}

917
web/app/routes/_index.tsx Normal file
View file

@ -0,0 +1,917 @@
import type { MetaFunction } from "@remix-run/node";
import { useState, useEffect, useTransition } from "react";
import {
Activity,
RefreshCw,
Trash2,
List,
FileText,
X,
ChevronRight,
ChevronDown,
Inbox,
Wrench,
Bot,
User,
Settings,
Zap,
Users,
Target,
Cpu,
MessageCircle,
Brain,
CheckCircle,
ClipboardCheck,
BarChart3,
MessageSquare,
Sparkles,
Copy,
Check,
Lightbulb,
Loader2
} from "lucide-react";
import RequestDetailContent from "../components/RequestDetailContent";
import { ConversationThread } from "../components/ConversationThread";
export const meta: MetaFunction = () => {
return [
{ title: "Claude Code Monitor" },
{ name: "description", content: "Claude Code Monitor - Real-time API request visualization" },
];
};
interface Request {
id: number;
conversationId?: string;
turnNumber?: number;
isRoot?: boolean;
timestamp: string;
method: string;
endpoint: string;
headers: Record<string, string[]>;
body?: {
model?: string;
messages?: Array<{
role: string;
content: any;
}>;
system?: Array<{
text: string;
type: string;
cache_control?: { type: string };
}>;
max_tokens?: number;
temperature?: number;
stream?: boolean;
};
response?: {
statusCode: number;
headers: Record<string, string[]>;
body?: any;
bodyText?: string;
responseTime: number;
streamingChunks?: string[];
isStreaming: boolean;
completedAt: string;
};
promptGrade?: {
score: number;
criteria: Record<string, { score: number; feedback: string }>;
feedback: string;
improvedPrompt: string;
gradingTimestamp: string;
};
}
interface ConversationSummary {
id: string;
requestCount: number;
startTime: string;
lastActivity: string;
duration: number;
firstMessage: string;
lastMessage: string;
projectName: string;
}
interface Conversation {
sessionId: string;
projectPath: string;
projectName: string;
messages: Array<{
parentUuid: string | null;
isSidechain: boolean;
userType: string;
cwd: string;
sessionId: string;
version: string;
type: 'user' | 'assistant';
message: any;
uuid: string;
timestamp: string;
}>;
startTime: string;
endTime: string;
messageCount: number;
}
export default function Index() {
const [requests, setRequests] = useState<Request[]>([]);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [selectedRequest, setSelectedRequest] = useState<Request | null>(null);
const [selectedConversation, setSelectedConversation] = useState<Conversation | null>(null);
const [filter, setFilter] = useState("all");
const [viewMode, setViewMode] = useState<"requests" | "conversations">("requests");
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConversationModalOpen, setIsConversationModalOpen] = useState(false);
const [modelFilter, setModelFilter] = useState<string>("all");
const [isFetching, setIsFetching] = useState(false);
const [isPending, startTransition] = useTransition();
const [requestsCurrentPage, setRequestsCurrentPage] = useState(1);
const [hasMoreRequests, setHasMoreRequests] = useState(true);
const [conversationsCurrentPage, setConversationsCurrentPage] = useState(1);
const [hasMoreConversations, setHasMoreConversations] = useState(true);
const itemsPerPage = 50;
const loadRequests = async (filter?: string, loadMore = false) => {
setIsFetching(true);
const pageToFetch = loadMore ? requestsCurrentPage + 1 : 1;
try {
const currentModelFilter = filter || modelFilter;
const url = new URL('/api/requests', 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) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const requests = data.requests || [];
const mappedRequests = requests.map((req: any, index: number) => ({
...req,
id: req.requestId ? `${req.requestId}_${index}` : `request_${index}`
}));
startTransition(() => {
if (loadMore) {
setRequests(prev => [...prev, ...mappedRequests]);
} else {
setRequests(mappedRequests);
}
setRequestsCurrentPage(pageToFetch);
setHasMoreRequests(mappedRequests.length === itemsPerPage);
});
} catch (error) {
console.error('Failed to load requests:', error);
// Fallback to example data for demo
const exampleRequest = {
timestamp: "2025-06-04T23:47:37-04:00",
method: "POST",
endpoint: "/v1/messages",
headers: {
"User-Agent": ["claude-cli/1.0.11 (external, cli)"],
"Content-Type": ["application/json"],
"Anthropic-Version": ["2023-06-01"]
},
body: {
model: "claude-sonnet-4-20250514",
messages: [
{
role: "user",
content: [{
type: "text",
text: "I need to extract the complete list of tools available to Claude Code from the request file..."
}]
}
],
max_tokens: 32000,
temperature: 1,
stream: true
}
};
startTransition(() => {
// setRequests([
// { ...exampleRequest, id: 1 },
// {
// ...exampleRequest,
// id: 2,
// timestamp: "2025-06-04T23:45:12-04:00",
// endpoint: "/v1/chat/completions",
// body: { ...exampleRequest.body, model: "gpt-4-turbo" }
// },
// {
// ...exampleRequest,
// id: 3,
// timestamp: "2025-06-04T23:42:33-04:00",
// method: "GET",
// endpoint: "/v1/models",
// body: undefined
// }
// ]);
});
} finally {
setIsFetching(false);
}
};
const loadConversations = async (filter?: string, 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) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
startTransition(() => {
if (loadMore) {
setConversations(prev => [...prev, ...data.conversations]);
} else {
setConversations(data.conversations);
}
setConversationsCurrentPage(pageToFetch);
setHasMoreConversations(data.conversations.length === itemsPerPage);
});
} catch (error) {
console.error('Failed to load conversations:', error);
startTransition(() => {
setConversations([]);
});
} finally {
setIsFetching(false);
}
};
const loadConversationDetails = async (conversationId: string, projectName: string) => {
try {
const response = await fetch(`/api/conversations/${conversationId}?project=${encodeURIComponent(projectName)}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const conversation = await response.json();
setSelectedConversation(conversation);
setIsConversationModalOpen(true);
} catch (error) {
console.error('Failed to load conversation details:', error);
}
};
const clearRequests = async () => {
try {
const response = await fetch('/api/requests', {
method: 'DELETE'
});
if (response.ok) {
setRequests([]);
setConversations([]);
setRequestsCurrentPage(1);
setHasMoreRequests(true);
setConversationsCurrentPage(1);
setHasMoreConversations(true);
}
} catch (error) {
console.error('Failed to clear requests:', error);
setRequests([]);
}
};
const filterRequests = (filter: string) => {
if (filter === 'all') return requests;
return requests.filter(req => {
switch (filter) {
case 'messages':
return req.endpoint.includes('/messages');
case 'completions':
return req.endpoint.includes('/completions');
case 'models':
return req.endpoint.includes('/models');
default:
return true;
}
});
};
const getMethodColor = (method: string) => {
const colors = {
'GET': 'bg-green-50 text-green-700 border border-green-200',
'POST': 'bg-blue-50 text-blue-700 border border-blue-200',
'PUT': 'bg-yellow-50 text-yellow-700 border border-yellow-200',
'DELETE': 'bg-red-50 text-red-700 border border-red-200'
};
return colors[method as keyof typeof colors] || 'bg-gray-50 text-gray-700 border border-gray-200';
};
const getRequestSummary = (request: Request) => {
if (request.body?.messages) {
const messageCount = request.body.messages.length;
// 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;
}
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';
};
const showRequestDetails = (requestId: number) => {
const request = requests.find(r => r.id === requestId);
if (request) {
setSelectedRequest(request);
setIsModalOpen(true);
}
};
const closeModal = () => {
setIsModalOpen(false);
setSelectedRequest(null);
};
const getToolStats = () => {
let toolDefinitions = 0;
let toolCalls = 0;
requests.forEach(req => {
if (req.body) {
// Count tool definitions in system prompts
if (req.body.system) {
req.body.system.forEach(sys => {
if (sys.text && sys.text.includes('<functions>')) {
const functionMatches = [...sys.text.matchAll(/<function>([\s\S]*?)<\/function>/g)];
toolDefinitions += functionMatches.length;
}
});
}
// Count actual tool calls in messages
if (req.body.messages) {
req.body.messages.forEach(msg => {
if (msg.content && Array.isArray(msg.content)) {
msg.content.forEach((contentPart: any) => {
if (contentPart.type === 'tool_use') {
toolCalls++;
}
if (contentPart.type === 'text' && contentPart.text && contentPart.text.includes('<functions>')) {
const functionMatches = [...contentPart.text.matchAll(/<function>([\s\S]*?)<\/function>/g)];
toolDefinitions += functionMatches.length;
}
});
}
});
}
}
});
return `${toolCalls} calls / ${toolDefinitions} tools`;
};
const getPromptGradeStats = () => {
let totalGrades = 0;
let gradeCount = 0;
requests.forEach(req => {
if (req.promptGrade && req.promptGrade.score) {
totalGrades += req.promptGrade.score;
gradeCount++;
}
});
if (gradeCount > 0) {
const avgGrade = (totalGrades / gradeCount).toFixed(1);
return `${avgGrade}/5`;
}
return '-/5';
};
const formatDuration = (milliseconds: number) => {
if (milliseconds < 60000) {
return `${Math.round(milliseconds / 1000)}s`;
} else if (milliseconds < 3600000) {
return `${Math.round(milliseconds / 60000)}m`;
} else {
return `${Math.round(milliseconds / 3600000)}h`;
}
};
const formatConversationSummary = (conversation: ConversationSummary) => {
const duration = formatDuration(conversation.duration);
return `${conversation.requestCount} requests • ${duration} duration`;
};
const canGradeRequest = (request: Request) => {
return request.body &&
request.body.messages &&
request.body.messages.some(msg => msg.role === 'user') &&
request.endpoint.includes('/messages');
};
const gradeRequest = async (requestId: number) => {
const request = requests.find(r => r.id === requestId);
if (!request || !canGradeRequest(request)) return;
try {
const response = await fetch('/api/grade-prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
messages: request.body!.messages,
systemMessages: request.body!.system || [],
requestId: request.timestamp
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const promptGrade = await response.json();
// Update the request with the new grading
const updatedRequests = requests.map(r =>
r.id === requestId ? { ...r, promptGrade } : r
);
setRequests(updatedRequests);
} catch (error) {
console.error('Failed to grade prompt:', error);
}
};
const handleModelFilterChange = (newFilter: string) => {
setModelFilter(newFilter);
if (viewMode === 'requests') {
loadRequests(newFilter);
} else {
loadConversations(newFilter);
}
};
useEffect(() => {
if (viewMode === 'requests') {
loadRequests(modelFilter);
} else {
loadConversations(modelFilter);
}
}, [viewMode, modelFilter]);
const filteredRequests = filterRequests(filter);
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">
<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>
</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">
<button
onClick={() => setViewMode("requests")}
className={`px-3 py-1.5 text-sm font-medium rounded-md 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 ${
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>
{/* 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="flex items-center justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-gray-500">
{viewMode === "requests" ? "Total Requests" : "Total Conversations"}
</p>
<p className="text-2xl font-semibold text-gray-900">
{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>
</div>
{/* 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="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> */}
</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>
) : 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>
) : (
<>
{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}
</span>
)}
</div>
<span className="text-gray-500 text-sm">{new Date(request.timestamp).toLocaleString()}</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>
</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">
<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"
>
{isFetching ? "Loading..." : "Load More"}
</button>
</div>
)}
</>
)}
</div>
</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>
<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>
) : 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>
) : (
<>
{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>
{conversation.projectName && (
<span className="text-xs text-purple-600 font-medium">{conversation.projectName}</span>
)}
</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>
</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">
<button
onClick={() => loadConversations(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"
>
{isFetching ? "Loading..." : "Load More"}
</button>
</div>
)}
</>
)}
</div>
</div>
)}
</main>
{/* Request Detail Modal */}
{isModalOpen && selectedRequest && (
<div className="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6">
<div className="bg-white rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<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">
<FileText className="w-5 h-5 text-blue-600" />
<h3 className="text-lg font-semibold text-gray-900">Request Details</h3>
</div>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
<RequestDetailContent request={selectedRequest} onGrade={() => gradeRequest(selectedRequest.id)} />
</div>
</div>
</div>
)}
{/* Conversation Detail Modal */}
{isConversationModalOpen && selectedConversation && (
<div className="fixed inset-0 bg-gray-900/70 backdrop-blur-sm z-50 flex items-center justify-center p-6">
<div className="bg-white rounded-xl max-w-6xl w-full max-h-[90vh] overflow-hidden shadow-2xl">
<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" />
<h3 className="text-lg font-semibold text-gray-900">
Conversation {selectedConversation.sessionId.slice(-8)}
</h3>
<span className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-2 py-1 rounded-full">
{selectedConversation.messageCount} messages
</span>
</div>
<button
onClick={() => setIsConversationModalOpen(false)}
className="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-100px)]">
<div className="space-y-6">
{/* Conversation Overview */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{selectedConversation.messageCount}</div>
<div className="text-sm text-gray-600">Messages</div>
</div>
<div className="text-center">
<div className="text-sm font-medium text-gray-700">{new Date(selectedConversation.startTime).toLocaleDateString()}</div>
<div className="text-sm text-gray-600">Started</div>
</div>
<div className="text-center">
<div className="text-sm font-medium text-gray-700">{new Date(selectedConversation.endTime).toLocaleDateString()}</div>
<div className="text-sm text-gray-600">Last Activity</div>
</div>
</div>
</div>
{/* Conversation Thread */}
<ConversationThread conversation={selectedConversation} />
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,26 @@
import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
export const loader: LoaderFunction = async ({ request }) => {
try {
const url = new URL(request.url);
const modelFilter = url.searchParams.get("model");
const backendUrl = new URL('http://localhost:3001/api/conversations');
if (modelFilter) {
backendUrl.searchParams.append('model', modelFilter);
}
const response = await fetch(backendUrl.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Failed to fetch conversations:', error);
return json({ conversations: [] });
}
};

View file

@ -0,0 +1,33 @@
import type { ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
export const action: ActionFunction = async ({ request }) => {
if (request.method !== "POST") {
return json({ error: 'Method not allowed' }, { status: 405 });
}
try {
const body = await request.json();
// Forward the request to the Go backend
const response = await fetch('http://localhost:3001/api/grade-prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Failed to grade prompt:', error);
return json({
error: 'Failed to grade prompt. Please ensure the backend is running and has a valid Anthropic API key.'
}, { status: 500 });
}
};

View file

@ -0,0 +1,61 @@
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
export const loader: LoaderFunction = async ({ request }) => {
try {
const url = new URL(request.url);
const modelFilter = url.searchParams.get("model");
const page = url.searchParams.get("page");
const limit = url.searchParams.get("limit");
// Forward the request to the Go backend
const backendUrl = new URL('http://localhost:3001/api/requests');
if (modelFilter) {
backendUrl.searchParams.append('model', modelFilter);
}
if (page) {
backendUrl.searchParams.append('page', page);
}
if (limit) {
backendUrl.searchParams.append('limit', limit);
}
const response = await fetch(backendUrl.toString());
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return json(data);
} catch (error) {
console.error('Failed to fetch requests:', error);
// Return empty array if backend is not available
return json({ requests: [] });
}
};
export const action: ActionFunction = async ({ request }) => {
const method = request.method;
if (method === "DELETE") {
try {
// Forward the DELETE request to the Go backend
const response = await fetch('http://localhost:3001/api/requests', {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return json({ success: true });
} catch (error) {
console.error('Failed to clear requests:', error);
return json({ success: false, error: 'Failed to clear requests' }, { status: 500 });
}
}
return json({ error: 'Method not allowed' }, { status: 405 });
};

238
web/app/tailwind.css Normal file
View file

@ -0,0 +1,238 @@
@tailwind base;
@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;
}
body {
background-color: #f9fafb;
color: #101828;
}
.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);
}
.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;
}
.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-custom::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.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;
}

174
web/app/utils/formatters.ts Normal file
View file

@ -0,0 +1,174 @@
/**
* Utility functions for formatting and displaying data
*/
/**
* Safely converts any value to a formatted string for display
*/
export function formatValue(value: any): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value, null, 2);
} catch (error) {
return String(value);
}
}
/**
* Formats JSON with proper indentation and returns a formatted string
*/
export function formatJSON(obj: any, maxLength: number = 1000): string {
try {
const jsonString = JSON.stringify(obj, null, 2);
if (jsonString.length > maxLength) {
return jsonString.substring(0, maxLength) + '...';
}
return jsonString;
} catch (error) {
return String(obj);
}
}
/**
* Escapes HTML characters to prevent XSS
*/
export function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Formats large text with proper line breaks and structure, optimized for the new conversation flow
*/
export function formatLargeText(text: string): string {
if (!text) return '';
// Escape HTML first
const escaped = escapeHtml(text);
// Format the text with proper spacing and structure
return escaped
// Preserve existing double line breaks
.replace(/\n\n/g, '<br><br>')
// Convert single line breaks to single <br> tags
.replace(/\n/g, '<br>')
// Format bullet points with modern styling
.replace(/^(\s*)([-*•])\s+(.+)$/gm, '$1<span class="inline-flex items-center space-x-2"><span class="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></span><span>$3</span></span>')
// Format numbered lists with modern styling
.replace(/^(\s*)(\d+)\.\s+(.+)$/gm, '$1<span class="inline-flex items-center space-x-2"><span class="w-5 h-5 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-semibold">$2</span><span>$3</span></span>')
// Format headers with better typography
.replace(/^([A-Z][^<\n]*:)(<br>|$)/gm, '<div class="font-semibold text-gray-900 mt-4 mb-2 border-b border-gray-200 pb-1">$1</div>$2')
// Format code blocks with better styling
.replace(/\b([A-Z_]{3,})\b/g, '<code class="bg-gradient-to-r from-gray-100 to-blue-50 border border-gray-200 px-2 py-0.5 rounded-md text-xs text-blue-700 font-mono font-medium">$1</code>')
// Format file paths and technical terms
.replace(/\b([a-zA-Z0-9_-]+\.[a-zA-Z]{2,4})\b/g, '<span class="bg-slate-100 text-slate-700 px-1.5 py-0.5 rounded text-xs font-mono border border-slate-200">$1</span>')
// Format URLs with modern link styling
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors font-medium" target="_blank" rel="noopener noreferrer">$1</a>')
// Format quoted text
.replace(/^(\s*)([""](.+?)[""])/gm, '$1<blockquote class="border-l-4 border-blue-200 bg-blue-50 pl-4 py-2 my-2 italic text-gray-700 rounded-r">$3</blockquote>')
// Add proper spacing around paragraphs
.replace(/(<br><br>)/g, '<div class="my-4"></div>')
// Clean up any excessive spacing
.replace(/(<br>\s*){3,}/g, '<br><br>')
// Format emphasis patterns
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>')
.replace(/\*([^*]+)\*/g, '<em class="italic text-gray-700">$1</em>')
// Format inline code
.replace(/`([^`]+)`/g, '<code class="bg-gray-100 text-gray-800 px-1.5 py-0.5 rounded text-sm font-mono border border-gray-200">$1</code>');
}
/**
* Determines if a value is a complex object that should be JSON-formatted
*/
export function isComplexObject(value: any): boolean {
return value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length > 0;
}
/**
* Truncates text to a specified length with ellipsis
*/
export function truncateText(text: string, maxLength: number = 200): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
/**
* Formats timestamp for display in the conversation flow
*/
export function formatTimestamp(timestamp: string | Date): string {
try {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
// Less than a minute ago
if (diff < 60000) {
return 'Just now';
}
// Less than an hour ago
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000);
return `${minutes}m ago`;
}
// Less than a day ago
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000);
return `${hours}h ago`;
}
// More than a day ago - show time
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return String(timestamp);
}
}
/**
* Formats file size for display
*/
export function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
/**
* Creates a content preview for message summaries
*/
export function createContentPreview(content: any, maxLength: number = 100): string {
if (typeof content === 'string') {
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
}
if (Array.isArray(content)) {
const textContent = content.find(c => c.type === 'text')?.text || '';
if (textContent) {
return textContent.length > maxLength ? textContent.substring(0, maxLength) + '...' : textContent;
}
return `${content.length} content blocks`;
}
if (content && typeof content === 'object') {
if (content.text) {
return content.text.length > maxLength ? content.text.substring(0, maxLength) + '...' : content.text;
}
return 'Complex content';
}
return 'No content';
}

13468
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

44
web/package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "web",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@remix-run/node": "^2.16.8",
"@remix-run/react": "^2.16.8",
"@remix-run/serve": "^2.16.8",
"isbot": "^4.1.0",
"lucide-react": "^0.522.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.16.8",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"autoprefixer": "^10.4.19",
"eslint": "^8.38.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.1.6",
"vite": "^6.0.0",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {
"node": ">=20.0.0"
}
}

6
web/postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
web/public/logo-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
web/public/logo-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

31
web/tailwind.config.ts Normal file
View file

@ -0,0 +1,31 @@
import type { Config } from "tailwindcss";
export default {
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {
fontFamily: {
sans: [
"Inter",
"ui-sans-serif",
"system-ui",
"sans-serif",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
animation: {
spin: 'spin 1s linear infinite',
},
keyframes: {
spin: {
from: { transform: 'rotate(0deg)' },
to: { transform: 'rotate(360deg)' },
},
},
},
},
plugins: [],
} satisfies Config;

32
web/tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
{
"include": [
"**/*.ts",
"**/*.tsx",
"**/.server/**/*.ts",
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["@remix-run/node", "vite/client"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2022",
"strict": true,
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
// Vite takes care of building everything, not tsc.
"noEmit": true
}
}

32
web/vite.config.ts Normal file
View file

@ -0,0 +1,32 @@
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
declare module "@remix-run/node" {
interface Future {
v3_singleFetch: true;
}
}
export default defineConfig({
plugins: [
remix({
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
server: {
proxy: {
"/api": {
target: "http://localhost:3001",
changeOrigin: true,
},
},
},
});