Fix sandbox compatibility, claude-proxy streaming, and rename playwright to browser

- Fix ListDir to support BusyBox/Alpine ls by falling back from GNU
  --time-style format, resolving CI test failures
- Update parseLS to handle both GNU (epoch) and BusyBox (date string) formats
- Fix claude-proxy streaming: always include usage in message_delta events
  to prevent Claude CLI from falling back to non-streaming mode
- Fix claude-proxy non-streaming: ensure usage is always present in responses
- Add paragraph separators between text blocks in Claude executor stream parser
- Translate VNC proxy UI from Chinese to English
- Rename sandbox-claude-playwright to sandbox-claude-browser across
  Dockerfiles, build scripts, and documentation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Max 2026-02-06 12:31:07 +08:00
parent 0a965c383d
commit 28363b973e
13 changed files with 190 additions and 104 deletions

View file

@ -561,11 +561,21 @@ func (e *Executor) parseStream(ctx *agentContext.Context, reader io.Reader, hand
switch eventType {
case "content_block_start":
// Check if this is a tool_use block starting
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"...","name":"Write","input":{}}}}
// Handle new content blocks
// Format: {"event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use"|"text",...}}}
if contentBlock, ok := event["content_block"].(map[string]interface{}); ok {
blockType, _ := contentBlock["type"].(string)
if blockType == "tool_use" {
switch blockType {
case "text":
// New text block starting - add paragraph separator if we already have content
// This ensures proper separation between text blocks across tool-use rounds
if textContent.Len() > 0 {
textContent.WriteString("\n\n")
if handler != nil && messageStarted {
handler(message.ChunkText, []byte("\n\n"))
}
}
case "tool_use":
toolName, _ := contentBlock["name"].(string)
blockIndex := 0
if idx, ok := event["index"].(float64); ok {

View file

@ -70,7 +70,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────┐ │
│ │ sandbox-claude │ │ sandbox-claude-playwright│ │ sandbox-claude- │ │
│ │ sandbox-claude │ │ sandbox-claude-browser │ │ sandbox-claude- │ │
│ │ (No VNC) │ │ (Browser + VNC) │ │ desktop (Full) │ │
│ │ │ │ │ │ │ │
│ │ • Claude CLI │ │ • Claude CLI │ │ • Claude CLI │ │
@ -89,7 +89,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users
| Image | VNC | Use Case | Size | Memory |
|-------|-----|----------|------|--------|
| `sandbox-claude` | ❌ | Code execution, scripts, CLI tasks | ~700MB | 2GB |
| `sandbox-claude-playwright` | ✅ | Browser automation, web scraping | ~1.8GB | 4GB |
| `sandbox-claude-browser` | ✅ | Browser automation, web scraping | ~1.8GB | 4GB |
| `sandbox-claude-desktop` | ✅ | Full visibility, any GUI app | ~2.5GB | 4GB |
### User Selection Flow
@ -105,7 +105,7 @@ The design provides **multiple sandbox image variants** with VNC support. Users
│ │ ○ Standard (sandbox-claude) ││
│ │ Code execution, no GUI. Lightweight and fast. ││
│ │ ││
│ │ ○ Browser (sandbox-claude-playwright) ⭐ ││
│ │ ○ Browser (sandbox-claude-browser) ⭐ ││
│ │ Playwright browser automation with VNC preview. ││
│ │ See browser operations in real-time. ││
│ │ ││
@ -130,11 +130,11 @@ Three image variants sharing the same VNC infrastructure:
ubuntu:24.04
└── sandbox-base:latest (~200MB)
└── sandbox-claude:latest (~700MB) # No VNC
├── sandbox-claude-playwright:latest (~1.8GB) # VNC + Browser
├── sandbox-claude-browser:latest (~1.8GB) # VNC + Browser
└── sandbox-claude-desktop:latest (~2.5GB) # VNC + Full Desktop
```
#### 1.1 sandbox-claude-playwright (Browser + VNC)
#### 1.1 sandbox-claude-browser (Browser + VNC)
For browser automation tasks with real-time visibility.
@ -219,7 +219,7 @@ type ImageType string
const (
ImageTypeClaude ImageType = "claude" // No VNC
ImageTypePlaywright ImageType = "playwright" // Browser + VNC
ImageTypeBrowser ImageType = "browser" // Browser + VNC
ImageTypeDesktop ImageType = "desktop" // Full desktop + VNC
)
@ -231,7 +231,7 @@ var ImageConfigs = map[ImageType]struct {
CPU float64
}{
ImageTypeClaude: {"yaoapp/sandbox-claude:latest", false, "2g", 1.0},
ImageTypePlaywright: {"yaoapp/sandbox-claude-playwright:latest", true, "4g", 2.0},
ImageTypeBrowser: {"yaoapp/sandbox-claude-browser:latest", true, "4g", 2.0},
ImageTypeDesktop: {"yaoapp/sandbox-claude-desktop:latest", true, "4g", 2.0},
}
```
@ -353,7 +353,7 @@ sandbox:
**Available Images**:
- `yaoapp/sandbox-claude:latest` - No VNC, lightweight
- `yaoapp/sandbox-claude-playwright:latest` - Browser + VNC
- `yaoapp/sandbox-claude-browser:latest` - Browser + VNC
- `yaoapp/sandbox-claude-desktop:latest` - Full desktop + VNC
**Note**: No changes required to `agent/sandbox/` code. The existing `Image` field in `SandboxConfig` already supports custom images.
@ -563,7 +563,7 @@ When user interaction is needed (e.g., login), Claude can:
## Implementation Details
### Dockerfile.playwright
### Dockerfile.browser (browser/Dockerfile)
```dockerfile
ARG REGISTRY=yaoapp
@ -1309,7 +1309,7 @@ Docker Desktop runs containers inside a LinuxKit VM, so container IPs (`172.17.0
```bash
# Enable VNC port mapping for local development
export YAO_SANDBOX_VNC_PORT_MAPPING=true
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-playwright:latest"
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-browser:latest"
```
When `YAO_SANDBOX_VNC_PORT_MAPPING=true`:
@ -1323,7 +1323,7 @@ On Linux (native Docker), this option is not needed as container IPs are directl
### Yao Backend ✅ 完成
- [x] `sandbox/docker/playwright/Dockerfile` - Playwright + VNC image
- [x] `sandbox/docker/browser/Dockerfile` - Browser + VNC image
- [x] `sandbox/docker/desktop/Dockerfile` - Full desktop + VNC image
- [x] `sandbox/docker/vnc/start-vnc.sh` - Shared VNC startup script
- [x] `sandbox/docker/vnc/entrypoint-vnc.sh` - VNC entrypoint
@ -1355,7 +1355,7 @@ yao/sandbox/
│ ├── claude/
│ │ ├── Dockerfile
│ │ └── Dockerfile.full
│ ├── playwright/ # Playwright + VNC image
│ ├── browser/ # Browser + VNC image
│ │ └── Dockerfile
│ ├── desktop/ # XFCE Desktop + VNC image
│ │ └── Dockerfile
@ -1408,12 +1408,12 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and
| Image | DISPLAY | VNC Visible | Agent Gets Output |
|-------|---------|-------------|-------------------|
| sandbox-claude | ❌ | N/A | ✅ |
| sandbox-claude-playwright | ✅ :99 | Browser window | ✅ |
| sandbox-claude-browser | ✅ :99 | Browser window | ✅ |
| sandbox-claude-desktop | ✅ :99 | Browser + Desktop apps | ✅ |
### What Users See in VNC
| Operation | sandbox-claude-playwright | sandbox-claude-desktop |
| Operation | sandbox-claude-browser | sandbox-claude-desktop |
|-----------|--------------------------|------------------------|
| Browser automation | ✅ Visible | ✅ Visible |
| File operations | ❌ | ✅ (open Thunar) |
@ -1435,7 +1435,7 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and
### A. Image Comparison
| Feature | sandbox-claude | sandbox-claude-playwright | sandbox-claude-desktop |
| Feature | sandbox-claude | sandbox-claude-browser | sandbox-claude-desktop |
|---------|---------------|--------------------------|------------------------|
| Claude CLI | ✅ | ✅ | ✅ |
| Node.js | ✅ | ✅ | ✅ |
@ -1453,7 +1453,7 @@ Commands execute identically across all sandbox images. The `Manager.Exec()` and
What users can see and do in VNC:
| Operation | sandbox-claude-playwright | sandbox-claude-desktop |
| Operation | sandbox-claude-browser | sandbox-claude-desktop |
|-----------|--------------------------|------------------------|
| Browser navigation | ✅ See | ✅ See |
| Browser clicks/typing | ✅ See | ✅ See |
@ -1481,7 +1481,7 @@ What users can see and do in VNC:
| Component | Location | Changes |
|-----------|----------|---------|
| Docker Images | `sandbox/docker/playwright/`, `sandbox/docker/desktop/` | NEW |
| Docker Images | `sandbox/docker/browser/`, `sandbox/docker/desktop/` | NEW |
| VNC Proxy | `sandbox/vncproxy/` | NEW |
| VNC API | Yao router | NEW endpoints |
| CUI | `cui/` | **No changes** (navigate action + iframe) |

View file

@ -60,7 +60,7 @@ cd sandbox/docker
./build.sh claude
# Build VNC-enabled images
./build.sh playwright # Playwright + Fluxbox + VNC
./build.sh browser # Browser (Playwright) + Fluxbox + VNC
./build.sh desktop # XFCE Desktop + VNC
# Build all images
@ -120,7 +120,7 @@ Docker Desktop runs containers in a LinuxKit VM, so container IPs are not direct
```bash
export YAO_SANDBOX_VNC_PORT_MAPPING=true
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-playwright:latest"
export YAO_SANDBOX_IMAGE="yaoapp/sandbox-claude-browser:latest"
```
When enabled, VNC ports (6080, 5900) are automatically mapped to random available host ports on `127.0.0.1`.
@ -132,7 +132,7 @@ When enabled, VNC ports (6080, 5900) are automatically mapped to random availabl
| `yaoapp/sandbox-base:latest` | ❌ | Base image with git, curl, yao-bridge |
| `yaoapp/sandbox-claude:latest` | ❌ | + Claude CLI, Node.js 20, Python 3.11 |
| `yaoapp/sandbox-claude:full` | ❌ | + Go 1.23 |
| `yaoapp/sandbox-claude-playwright:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
| `yaoapp/sandbox-claude-browser:latest` | ✅ | + Playwright, Fluxbox, VNC (~3.4GB) |
| `yaoapp/sandbox-claude-desktop:latest` | ✅ | + XFCE Desktop, VNC (~3.1GB) |
## IPC Communication
@ -172,7 +172,7 @@ sandbox/
├── docker/ # Dockerfiles and build script
│ ├── base/
│ ├── claude/
│ ├── playwright/ # Playwright + VNC image
│ ├── browser/ # Browser (Playwright) + VNC image
│ ├── desktop/ # XFCE Desktop + VNC image
│ ├── vnc/ # Shared VNC scripts
│ └── build.sh

View file

@ -1,8 +1,9 @@
# Claude sandbox with Playwright browser automation + VNC preview
# Image: sandbox-claude-playwright
# Claude sandbox with browser automation + VNC preview
# Image: sandbox-claude-browser
# Base: sandbox-claude (Ubuntu 24.04 + Node.js + Python + Claude CLI)
# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright browsers
# Adds: Xvfb + x11vnc + noVNC + Fluxbox + Playwright/Puppeteer browsers
#
# Lightweight browser environment for web automation tasks
# Supports both amd64 and arm64 architectures
ARG REGISTRY=yaoapp
@ -66,8 +67,8 @@ RUN mkdir -p /usr/local/share/yao
# Note: Build context should be sandbox/docker/, so paths are relative to that
COPY vnc/start-vnc.sh /usr/local/bin/start-vnc.sh
COPY vnc/entrypoint-vnc.sh /usr/local/bin/entrypoint.sh
COPY playwright/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh
COPY playwright/config/yao-logo.png /usr/local/share/yao/yao-logo.png
COPY browser/config/setup-fluxbox.sh /usr/local/bin/setup-fluxbox.sh
COPY browser/config/yao-logo.png /usr/local/share/yao/yao-logo.png
RUN chmod +x /usr/local/bin/start-vnc.sh /usr/local/bin/entrypoint.sh /usr/local/bin/setup-fluxbox.sh
# Environment variables for VNC

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -107,14 +107,14 @@ case $TOOL in
;;
claude-vnc)
echo ""
echo "=== Building Claude VNC images (Playwright + Desktop) ==="
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
echo "=== Building Claude VNC images (Browser + Desktop) ==="
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
;;
playwright)
browser)
echo ""
echo "=== Building Claude Playwright image ==="
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
echo "=== Building Claude Browser image ==="
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
;;
desktop)
echo ""
@ -133,18 +133,18 @@ case $TOOL in
build_multiarch "sandbox-claude" "claude/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-full" "claude/Dockerfile.full" "$PUSH"
# Claude VNC variants
build_multiarch "sandbox-claude-playwright" "playwright/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-browser" "browser/Dockerfile" "$PUSH"
build_multiarch "sandbox-claude-desktop" "desktop/Dockerfile" "$PUSH"
# Cursor (uncomment when ready)
# build_multiarch "sandbox-cursor" "cursor/Dockerfile" "$PUSH"
;;
*)
echo "Unknown tool: $TOOL"
echo "Usage: $0 [claude|claude-vnc|playwright|desktop|cursor|all] [true|false]"
echo "Usage: $0 [claude|claude-vnc|browser|desktop|cursor|all] [true|false]"
echo " $0 claude # Build Claude images locally"
echo " $0 claude true # Build and push Claude images"
echo " $0 claude-vnc # Build Claude VNC images (Playwright + Desktop)"
echo " $0 playwright # Build Claude Playwright image only"
echo " $0 claude-vnc # Build Claude VNC images (Browser + Desktop)"
echo " $0 browser # Build Claude Browser image only"
echo " $0 desktop # Build Claude Desktop image only"
echo " $0 all true # Build and push all images"
exit 1
@ -165,11 +165,11 @@ if [ "$PUSH" = "true" ]; then
echo " - ${REGISTRY}/sandbox-claude-full:latest"
;;
claude-vnc)
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
playwright)
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
browser)
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
;;
desktop)
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
@ -177,7 +177,7 @@ if [ "$PUSH" = "true" ]; then
all)
echo " - ${REGISTRY}/sandbox-claude:latest"
echo " - ${REGISTRY}/sandbox-claude-full:latest"
echo " - ${REGISTRY}/sandbox-claude-playwright:latest"
echo " - ${REGISTRY}/sandbox-claude-browser:latest"
echo " - ${REGISTRY}/sandbox-claude-desktop:latest"
;;
esac

View file

@ -1,6 +1,6 @@
#!/bin/bash
# VNC services startup script
# Shared by sandbox-claude-playwright and sandbox-claude-desktop
# Shared by sandbox-claude-browser and sandbox-claude-desktop
# Starts: Xvfb (virtual display) + Window Manager + x11vnc + websockify (noVNC)
set -e

View file

@ -58,8 +58,10 @@ func parseMemory(s string) int64 {
}
}
// parseLS parses ls -la --time-style=+%s output to []FileInfo
func parseLS(output string) []FileInfo {
// parseLS parses ls -la output to []FileInfo
// If hasTimeStyle is true, expects GNU ls output with --time-style=+%s (Unix epoch)
// If hasTimeStyle is false, expects BusyBox/basic ls output (date string format)
func parseLS(output string, hasTimeStyle bool) []FileInfo {
lines := strings.Split(strings.TrimSpace(output), "\n")
var result []FileInfo
@ -69,9 +71,19 @@ func parseLS(output string) []FileInfo {
continue
}
// Parse ls -la output: drwxr-xr-x 2 user group 4096 1234567890 filename
// Parse ls -la output
// GNU with --time-style: drwxr-xr-x 2 user group 4096 1234567890 filename
// BusyBox/basic: drwxr-xr-x 2 user group 4096 Jan 1 12:00 filename
fields := strings.Fields(line)
if len(fields) < 7 {
var minFields int
if hasTimeStyle {
minFields = 7 // mode, links, user, group, size, timestamp, name
} else {
minFields = 9 // mode, links, user, group, size, month, day, time/year, name
}
if len(fields) < minFields {
continue
}
@ -85,12 +97,21 @@ func parseLS(output string) []FileInfo {
// Parse size
size, _ := strconv.ParseInt(fields[4], 10, 64)
// Parse timestamp (Unix epoch)
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
modTime := time.Unix(timestamp, 0)
// Parse timestamp and get filename
var modTime time.Time
var name string
// Get filename (may contain spaces)
name := strings.Join(fields[6:], " ")
if hasTimeStyle {
// GNU ls with --time-style=+%s: timestamp is Unix epoch in fields[5]
timestamp, _ := strconv.ParseInt(fields[5], 10, 64)
modTime = time.Unix(timestamp, 0)
name = strings.Join(fields[6:], " ")
} else {
// BusyBox/basic ls: date is in fields[5:8] (e.g., "Jan 1 12:00" or "Jan 1 2024")
// Note: time.Now() is used as fallback since BusyBox date parsing is complex
modTime = time.Now()
name = strings.Join(fields[8:], " ")
}
// Skip . and ..
if name == "." || name == ".." {

View file

@ -65,41 +65,83 @@ func TestMapToSlice(t *testing.T) {
}
func TestParseLS(t *testing.T) {
output := `total 8
// Test GNU ls output with --time-style=+%s (Unix epoch timestamp)
t.Run("GNU_ls_with_time_style", func(t *testing.T) {
output := `total 8
drwxr-xr-x 2 sandbox sandbox 4096 1700000000 dir1
-rw-r--r-- 1 sandbox sandbox 100 1700000001 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 1700000002 link1 -> file1.txt
`
result := parseLS(output)
result := parseLS(output, true)
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check link1
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
// Check link1
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
})
// Test BusyBox/basic ls output (Alpine-style)
t.Run("BusyBox_ls_basic", func(t *testing.T) {
output := `total 8
drwxr-xr-x 2 sandbox sandbox 4096 Jan 1 12:00 dir1
-rw-r--r-- 1 sandbox sandbox 100 Jan 1 12:01 file1.txt
lrwxrwxrwx 1 sandbox sandbox 10 Jan 1 12:02 link1 -> file1.txt
`
result := parseLS(output, false)
if len(result) != 3 {
t.Fatalf("expected 3 items, got %d", len(result))
}
// Check dir1
if result[0].Name != "dir1" {
t.Errorf("expected name 'dir1', got '%s'", result[0].Name)
}
if !result[0].IsDir {
t.Errorf("expected dir1 to be a directory")
}
// Check file1.txt
if result[1].Name != "file1.txt" {
t.Errorf("expected name 'file1.txt', got '%s'", result[1].Name)
}
if result[1].Size != 100 {
t.Errorf("expected size 100, got %d", result[1].Size)
}
if result[1].IsDir {
t.Errorf("expected file1.txt to be a file, not directory")
}
// Check link1 (in BusyBox format, symlink target is separate field)
if result[2].Name != "link1 -> file1.txt" {
t.Errorf("expected name 'link1 -> file1.txt', got '%s'", result[2].Name)
}
})
}
func TestParseStat(t *testing.T) {

View file

@ -811,12 +811,22 @@ func (m *Manager) ReadFile(ctx context.Context, name, path string) ([]byte, erro
// ListDir lists directory contents in container
func (m *Manager) ListDir(ctx context.Context, name, path string) ([]FileInfo, error) {
// Try GNU ls with --time-style first (for GNU coreutils)
result, err := m.Exec(ctx, name, []string{"ls", "-la", "--time-style=+%s", path}, nil)
if err == nil && result.ExitCode == 0 {
return parseLS(result.Stdout, true), nil
}
// Fall back to basic ls (for BusyBox/Alpine)
result, err = m.Exec(ctx, name, []string{"ls", "-la", path}, nil)
if err != nil {
return nil, err
}
if result.ExitCode != 0 {
return nil, fmt.Errorf("ls failed: %s", result.Stderr)
}
return parseLS(result.Stdout), nil
return parseLS(result.Stdout, false), nil
}
// Stat returns file info

View file

@ -313,12 +313,14 @@ func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
result.StopReason = &stopReason
}
// Convert usage
// Convert usage (always include - Claude CLI expects usage to be present)
if resp.Usage != nil {
result.Usage = &Usage{
InputTokens: resp.Usage.PromptTokens,
OutputTokens: resp.Usage.CompletionTokens,
}
} else {
result.Usage = &Usage{InputTokens: 0, OutputTokens: 0}
}
return result

View file

@ -310,6 +310,7 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
var toolCalls []*ToolCallAccumulator
var contentIndex int
var finishReason string
var lastUsage *Usage // Track the latest usage data from backend
for scanner.Scan() {
line := scanner.Text()
@ -332,19 +333,13 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
}
if len(chunk.Choices) == 0 {
// Usage update at the end
// Usage update at the end - save it but don't send message_delta yet
// It will be included in the final message_delta below
if chunk.Usage != nil {
usageEvent := AnthropicStreamEvent{
Type: "message_delta",
Delta: &DeltaContent{
StopReason: &finishReason,
},
Usage: &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
},
lastUsage = &Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
}
s.writeSSE(w, flusher, usageEvent)
}
continue
}
@ -452,15 +447,20 @@ func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body
s.writeSSE(w, flusher, stopEvent)
}
// Send message_delta with stop reason
// Send message_delta with stop reason and usage
// Claude CLI expects usage to always be present in message_delta
if finishReason == "" {
finishReason = "end_turn"
}
if lastUsage == nil {
lastUsage = &Usage{InputTokens: 0, OutputTokens: 0}
}
deltaEvent := AnthropicStreamEvent{
Type: "message_delta",
Delta: &DeltaContent{
StopReason: &finishReason,
},
Usage: lastUsage,
}
s.writeSSE(w, flusher, deltaEvent)

View file

@ -380,11 +380,11 @@ func (p *Proxy) checkVNCReady(ctx context.Context, containerName string) bool {
// serveNoVNCPage serves an inline HTML page with noVNC client
func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string, viewOnly bool) {
viewOnlyStr := "false"
modeIndicator := "可交互"
modeIndicator := "Interactive"
modeColor := "#4CAF50"
if viewOnly {
viewOnlyStr = "true"
modeIndicator = "只读模式"
modeIndicator = "View Only"
modeColor = "#FF9800"
}
@ -423,7 +423,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
<body>
<div id="loading">
<div class="spinner"></div>
<div id="status">正在连接 Sandbox...</div>
<div id="status">Connecting to Sandbox...</div>
<div id="retry-count"></div>
<div id="error"></div>
</div>
@ -454,25 +454,25 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
const data = await res.json();
if (data.status === 'ready') {
status.textContent = '正在初始化显示...';
status.textContent = 'Initializing display...';
connectVNC();
return;
}
if (data.status === 'starting') {
status.textContent = 'Sandbox 启动中...';
status.textContent = 'Sandbox starting...';
} else if (data.status === 'not_supported') {
showError('Sandbox 不支持可视化');
showError('This Sandbox does not support visualization');
return;
} else {
status.textContent = '等待容器就绪...';
status.textContent = 'Waiting for container to be ready...';
}
retryCount++;
retryCountEl.textContent = '重试 ' + retryCount + '/' + maxRetries;
retryCountEl.textContent = 'Retry ' + retryCount + '/' + maxRetries;
if (retryCount >= maxRetries) {
showError('连接超时请稍后重试');
showError('Connection timeout, please try again later');
return;
}
@ -480,7 +480,7 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
} catch (err) {
retryCount++;
if (retryCount >= maxRetries) {
showError('无法连接到服务器');
showError('Unable to connect to server');
return;
}
setTimeout(checkStatus, 1000);
@ -518,9 +518,9 @@ func (p *Proxy) serveNoVNCPage(w http.ResponseWriter, sandboxID, wsPath string,
screen.style.display = 'none';
modeIndicator.style.display = 'none';
if (e.detail.clean) {
status.textContent = '连接已关闭';
status.textContent = 'Connection closed';
} else {
showError('连接已断开');
showError('Connection lost');
}
});
}