Ready
This commit is contained in:
commit
ae71ec4f72
48 changed files with 21032 additions and 0 deletions
16
.env.example
Normal file
16
.env.example
Normal 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
41
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
62
Makefile
Normal 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
151
README.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Claude Code Monitor
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
BIN
demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
106
proxy/cmd/proxy/main.go
Normal file
106
proxy/cmd/proxy/main.go
Normal 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
13
proxy/go.mod
Normal 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
8
proxy/go.sum
Normal 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=
|
||||||
87
proxy/internal/config/config.go
Normal file
87
proxy/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
689
proxy/internal/handler/handlers.go
Normal file
689
proxy/internal/handler/handlers.go
Normal 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)
|
||||||
|
}
|
||||||
265
proxy/internal/handler/utils.go
Normal file
265
proxy/internal/handler/utils.go
Normal 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
|
||||||
|
}
|
||||||
99
proxy/internal/middleware/logging.go
Normal file
99
proxy/internal/middleware/logging.go
Normal 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)
|
||||||
|
}
|
||||||
203
proxy/internal/model/models.go
Normal file
203
proxy/internal/model/models.go
Normal 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"`
|
||||||
|
}
|
||||||
291
proxy/internal/service/anthropic.go
Normal file
291
proxy/internal/service/anthropic.go
Normal 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
|
||||||
|
}
|
||||||
306
proxy/internal/service/conversation.go
Normal file
306
proxy/internal/service/conversation.go
Normal 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
|
||||||
|
}
|
||||||
18
proxy/internal/service/storage.go
Normal file
18
proxy/internal/service/storage.go
Normal 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)
|
||||||
|
}
|
||||||
386
proxy/internal/service/storage_sqlite.go
Normal file
386
proxy/internal/service/storage_sqlite.go
Normal 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
91
run.sh
Executable 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
84
web/.eslintrc.cjs
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
103
web/app/components/CodeDiff.tsx
Normal file
103
web/app/components/CodeDiff.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
web/app/components/CodeViewer.tsx
Normal file
236
web/app/components/CodeViewer.tsx
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
web/app/components/ConversationThread.tsx
Normal file
215
web/app/components/ConversationThread.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
web/app/components/ImageContent.tsx
Normal file
144
web/app/components/ImageContent.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
400
web/app/components/MessageContent.tsx
Normal file
400
web/app/components/MessageContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
web/app/components/MessageFlow.tsx
Normal file
282
web/app/components/MessageFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
789
web/app/components/RequestDetailContent.tsx
Normal file
789
web/app/components/RequestDetailContent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
web/app/components/TodoList.tsx
Normal file
195
web/app/components/TodoList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
web/app/components/ToolResult.tsx
Normal file
257
web/app/components/ToolResult.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
web/app/components/ToolUse.tsx
Normal file
209
web/app/components/ToolUse.tsx
Normal 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
18
web/app/entry.client.tsx
Normal 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
140
web/app/entry.server.tsx
Normal 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
45
web/app/root.tsx
Normal 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
917
web/app/routes/_index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
web/app/routes/api.conversations.tsx
Normal file
26
web/app/routes/api.conversations.tsx
Normal 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: [] });
|
||||||
|
}
|
||||||
|
};
|
||||||
33
web/app/routes/api.grade-prompt.tsx
Normal file
33
web/app/routes/api.grade-prompt.tsx
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
61
web/app/routes/api.requests.tsx
Normal file
61
web/app/routes/api.requests.tsx
Normal 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
238
web/app/tailwind.css
Normal 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
174
web/app/utils/formatters.ts
Normal 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
13468
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
web/package.json
Normal file
44
web/package.json
Normal 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
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
web/public/favicon.ico
Normal file
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
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
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
31
web/tailwind.config.ts
Normal 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
32
web/tsconfig.json
Normal 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
32
web/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue