@@ -3,16 +3,26 @@ package claudetool
import (
"context"
"encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
"shelley.exe.dev/llm"
)
// OutputIframeTool displays sandboxed HTML content to the user.
-var OutputIframeTool = &llm.Tool{
- Name: outputIframeName,
- Description: outputIframeDescription,
- InputSchema: llm.MustSchema(outputIframeInputSchema),
- Run: outputIframeRun,
+// It requires a MutableWorkingDir to resolve relative file paths.
+type OutputIframeTool struct {
+ WorkingDir *MutableWorkingDir
+}
+
+func (t *OutputIframeTool) Tool() *llm.Tool {
+ return &llm.Tool{
+ Name: outputIframeName,
+ Description: outputIframeDescription,
+ InputSchema: llm.MustSchema(outputIframeInputSchema),
+ Run: t.Run,
+ }
}
const (
@@ -34,50 +44,217 @@ Good uses:
- SVG graphics
The HTML should be self-contained. You can include inline <script> and <style> tags.
-External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).`
+External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).
+
+For visualizations that need external data files (JSON, CSV, etc.), use the 'files' parameter
+to bundle them. They will be injected into the page and accessible via window.__FILES__['filename'].`
outputIframeInputSchema = `
{
"type": "object",
- "required": ["html"],
+ "required": ["path"],
"properties": {
- "html": {
+ "path": {
"type": "string",
- "description": "The HTML content to display. Should be a complete HTML document or fragment."
+ "description": "Path to the HTML file to display. Relative paths are resolved from the working directory."
},
"title": {
"type": "string",
"description": "Optional title describing the visualization"
+ },
+ "files": {
+ "type": "object",
+ "description": "Additional files to bundle (e.g., data.json, styles.css). Keys are the names to use in the HTML, values are file paths. Files are accessible in the HTML via window.__FILES__['filename'] for JSON/text, or injected as style tags for CSS.",
+ "additionalProperties": {
+ "type": "string"
+ }
}
}
}
`
)
+// EmbeddedFile represents a file bundled with the HTML.
+type EmbeddedFile struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Content string `json:"content"`
+ Type string `json:"type"` // "json", "css", "js", "text"
+}
+
// OutputIframeDisplay is the data passed to the UI for rendering.
type OutputIframeDisplay struct {
- Type string `json:"type"`
- HTML string `json:"html"`
- Title string `json:"title,omitempty"`
+ Type string `json:"type"`
+ HTML string `json:"html"`
+ Title string `json:"title,omitempty"`
+ Filename string `json:"filename,omitempty"`
+ Files []EmbeddedFile `json:"files,omitempty"`
+}
+
+// detectFileType guesses the file type from the filename.
+func detectFileType(filename string) string {
+ ext := strings.ToLower(filepath.Ext(filename))
+ switch ext {
+ case ".json":
+ return "json"
+ case ".css":
+ return "css"
+ case ".js":
+ return "js"
+ case ".csv":
+ return "csv"
+ default:
+ return "text"
+ }
+}
+
+// injectFiles modifies the HTML to make bundled files accessible.
+// For JSON/text/csv files: adds a script that populates window.__FILES__
+// For CSS files: injects as <style> tags
+// For JS files: injects as <script> tags
+func injectFiles(html string, files []EmbeddedFile) string {
+ if len(files) == 0 {
+ return html
+ }
+
+ var jsFiles []EmbeddedFile
+ var cssFiles []EmbeddedFile
+ var dataFiles []EmbeddedFile
+
+ for _, f := range files {
+ switch f.Type {
+ case "css":
+ cssFiles = append(cssFiles, f)
+ case "js":
+ jsFiles = append(jsFiles, f)
+ default:
+ dataFiles = append(dataFiles, f)
+ }
+ }
+
+ var injection strings.Builder
+
+ // Inject CSS files as style tags
+ for _, f := range cssFiles {
+ injection.WriteString("<style data-file=\"")
+ injection.WriteString(f.Name)
+ injection.WriteString("\">\n")
+ injection.WriteString(f.Content)
+ injection.WriteString("\n</style>\n")
+ }
+
+ // Inject data files as window.__FILES__
+ if len(dataFiles) > 0 {
+ injection.WriteString("<script>\nwindow.__FILES__ = window.__FILES__ || {};\n")
+ for _, f := range dataFiles {
+ // Escape the content for JavaScript string
+ escaped := escapeJSString(f.Content)
+ injection.WriteString("window.__FILES__[\"")
+ injection.WriteString(f.Name)
+ injection.WriteString("\"] = \"")
+ injection.WriteString(escaped)
+ injection.WriteString("\";\n")
+ }
+ injection.WriteString("</script>\n")
+ }
+
+ // Inject JS files as script tags
+ for _, f := range jsFiles {
+ injection.WriteString("<script data-file=\"")
+ injection.WriteString(f.Name)
+ injection.WriteString("\">\n")
+ injection.WriteString(f.Content)
+ injection.WriteString("\n</script>\n")
+ }
+
+ // Insert after <head> or at the beginning
+ injectionStr := injection.String()
+ if idx := strings.Index(strings.ToLower(html), "<head>"); idx != -1 {
+ return html[:idx+6] + "\n" + injectionStr + html[idx+6:]
+ }
+ if idx := strings.Index(strings.ToLower(html), "<html>"); idx != -1 {
+ return html[:idx+6] + "\n<head>\n" + injectionStr + "</head>\n" + html[idx+6:]
+ }
+ // No head or html tag, just prepend
+ return injectionStr + html
+}
+
+// escapeJSString escapes a string for use in a JavaScript string literal.
+func escapeJSString(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ switch r {
+ case '\\':
+ b.WriteString("\\\\")
+ case '"':
+ b.WriteString("\\\"")
+ case '\n':
+ b.WriteString("\\n")
+ case '\r':
+ b.WriteString("\\r")
+ case '\t':
+ b.WriteString("\\t")
+ default:
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
}
-func outputIframeRun(ctx context.Context, m json.RawMessage) llm.ToolOut {
+func (t *OutputIframeTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
var input struct {
- HTML string `json:"html"`
- Title string `json:"title"`
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Files map[string]string `json:"files"`
}
if err := json.Unmarshal(m, &input); err != nil {
return llm.ErrorToolOut(err)
}
- if input.HTML == "" {
- return llm.ErrorfToolOut("html content is required")
+ if input.Path == "" {
+ return llm.ErrorfToolOut("path is required")
}
+ // Resolve the path relative to working directory
+ path := input.Path
+ if !filepath.IsAbs(path) {
+ path = filepath.Join(t.WorkingDir.Get(), path)
+ }
+
+ // Read the main HTML file
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to read file: %v", err)
+ }
+
+ // Read additional files
+ var embeddedFiles []EmbeddedFile
+ for name, filePath := range input.Files {
+ // Resolve relative paths
+ if !filepath.IsAbs(filePath) {
+ filePath = filepath.Join(t.WorkingDir.Get(), filePath)
+ }
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return llm.ErrorfToolOut("failed to read file %q: %v", name, err)
+ }
+ embeddedFiles = append(embeddedFiles, EmbeddedFile{
+ Name: name,
+ Path: input.Files[name], // Original path for download
+ Content: string(content),
+ Type: detectFileType(name),
+ })
+ }
+
+ // Inject files into the HTML for iframe display
+ html := injectFiles(string(data), embeddedFiles)
+
display := OutputIframeDisplay{
- Type: "output_iframe",
- HTML: input.HTML,
- Title: input.Title,
+ Type: "output_iframe",
+ HTML: html,
+ Title: input.Title,
+ Filename: filepath.Base(input.Path),
+ Files: embeddedFiles,
}
return llm.ToolOut{
@@ -3,45 +3,150 @@ package claudetool
import (
"context"
"encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
"testing"
)
func TestOutputIframeRun(t *testing.T) {
+ // Create a temp directory for test files
+ tmpDir := t.TempDir()
+
+ // Create test HTML files
+ htmlFile := filepath.Join(tmpDir, "test.html")
+ if err := os.WriteFile(htmlFile, []byte("<html><head></head><body><h1>Hello</h1></body></html>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ chartFile := filepath.Join(tmpDir, "chart.html")
+ if err := os.WriteFile(chartFile, []byte("<html><head></head><body><div>Chart</div></body></html>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a data file
+ dataFile := filepath.Join(tmpDir, "data.json")
+ if err := os.WriteFile(dataFile, []byte(`{"values": [1, 2, 3]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create a CSS file
+ cssFile := filepath.Join(tmpDir, "styles.css")
+ if err := os.WriteFile(cssFile, []byte("body { color: red; }"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ workingDir := &MutableWorkingDir{}
+ workingDir.Set(tmpDir)
+
+ tool := &OutputIframeTool{WorkingDir: workingDir}
+
tests := []struct {
- name string
- input map[string]any
- wantErr bool
- wantTitle string
+ name string
+ input map[string]any
+ wantErr bool
+ wantTitle string
+ wantFilename string
+ wantFiles int
+ checkHTML func(html string) bool
}{
{
- name: "basic html",
+ name: "basic html file",
input: map[string]any{
- "html": "<h1>Hello</h1>",
+ "path": "test.html",
},
- wantErr: false,
- wantTitle: "",
+ wantErr: false,
+ wantTitle: "",
+ wantFilename: "test.html",
+ wantFiles: 0,
},
{
name: "html with title",
input: map[string]any{
- "html": "<div>Chart</div>",
+ "path": "chart.html",
"title": "My Chart",
},
- wantErr: false,
- wantTitle: "My Chart",
+ wantErr: false,
+ wantTitle: "My Chart",
+ wantFilename: "chart.html",
+ wantFiles: 0,
+ },
+ {
+ name: "html with data file",
+ input: map[string]any{
+ "path": "chart.html",
+ "title": "Chart with Data",
+ "files": map[string]any{
+ "data.json": "data.json",
+ },
+ },
+ wantErr: false,
+ wantTitle: "Chart with Data",
+ wantFilename: "chart.html",
+ wantFiles: 1,
+ checkHTML: func(html string) bool {
+ return strings.Contains(html, "window.__FILES__") &&
+ strings.Contains(html, "data.json")
+ },
+ },
+ {
+ name: "html with multiple files",
+ input: map[string]any{
+ "path": "chart.html",
+ "title": "Styled Chart",
+ "files": map[string]any{
+ "data.json": "data.json",
+ "styles.css": "styles.css",
+ },
+ },
+ wantErr: false,
+ wantTitle: "Styled Chart",
+ wantFilename: "chart.html",
+ wantFiles: 2,
+ checkHTML: func(html string) bool {
+ return strings.Contains(html, "window.__FILES__") &&
+ strings.Contains(html, "<style data-file=\"styles.css\">") &&
+ strings.Contains(html, "body { color: red; }")
+ },
+ },
+ {
+ name: "absolute path",
+ input: map[string]any{
+ "path": htmlFile,
+ },
+ wantErr: false,
+ wantFilename: "test.html",
+ wantFiles: 0,
},
{
- name: "empty html",
+ name: "empty path",
input: map[string]any{
- "html": "",
+ "path": "",
},
wantErr: true,
},
{
- name: "missing html",
+ name: "missing path",
input: map[string]any{},
wantErr: true,
},
+ {
+ name: "nonexistent file",
+ input: map[string]any{
+ "path": "nonexistent.html",
+ },
+ wantErr: true,
+ },
+ {
+ name: "nonexistent data file",
+ input: map[string]any{
+ "path": "chart.html",
+ "files": map[string]any{
+ "missing.json": "missing.json",
+ },
+ },
+ wantErr: true,
+ },
}
for _, tt := range tests {
@@ -51,7 +156,7 @@ func TestOutputIframeRun(t *testing.T) {
t.Fatalf("failed to marshal input: %v", err)
}
- result := outputIframeRun(context.Background(), inputJSON)
+ result := tool.Run(context.Background(), inputJSON)
if tt.wantErr {
if result.Error == nil {
@@ -83,24 +188,154 @@ func TestOutputIframeRun(t *testing.T) {
t.Errorf("expected Title %q, got %q", tt.wantTitle, display.Title)
}
- if html, _ := tt.input["html"].(string); display.HTML != html {
- t.Errorf("expected HTML %q, got %q", html, display.HTML)
+ if display.Filename != tt.wantFilename {
+ t.Errorf("expected Filename %q, got %q", tt.wantFilename, display.Filename)
+ }
+
+ if len(display.Files) != tt.wantFiles {
+ t.Errorf("expected %d files, got %d", tt.wantFiles, len(display.Files))
+ }
+
+ if tt.checkHTML != nil && !tt.checkHTML(display.HTML) {
+ t.Errorf("HTML check failed, got: %s", display.HTML)
+ }
+ })
+ }
+}
+
+func TestDetectFileType(t *testing.T) {
+ tests := []struct {
+ filename string
+ want string
+ }{
+ {"data.json", "json"},
+ {"styles.css", "css"},
+ {"script.js", "js"},
+ {"data.csv", "csv"},
+ {"readme.txt", "text"},
+ {"unknown", "text"},
+ {"DATA.JSON", "json"}, // case insensitive
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.filename, func(t *testing.T) {
+ got := detectFileType(tt.filename)
+ if got != tt.want {
+ t.Errorf("detectFileType(%q) = %q, want %q", tt.filename, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestEscapeJSString(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"hello", "hello"},
+ {"hello\nworld", "hello\\nworld"},
+ {`say "hi"`, `say \"hi\"`},
+ {"back\\slash", "back\\\\slash"},
+ {"tab\there", "tab\\there"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := escapeJSString(tt.input)
+ if got != tt.want {
+ t.Errorf("escapeJSString(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestInjectFiles(t *testing.T) {
+ tests := []struct {
+ name string
+ html string
+ files []EmbeddedFile
+ contains []string
+ }{
+ {
+ name: "no files",
+ html: "<html><head></head><body></body></html>",
+ files: nil,
+ contains: []string{"<html><head></head><body></body></html>"},
+ },
+ {
+ name: "inject json file",
+ html: "<html><head></head><body></body></html>",
+ files: []EmbeddedFile{
+ {Name: "data.json", Content: `{"x": 1}`, Type: "json"},
+ },
+ contains: []string{"window.__FILES__", "data.json"},
+ },
+ {
+ name: "inject css file",
+ html: "<html><head></head><body></body></html>",
+ files: []EmbeddedFile{
+ {Name: "styles.css", Content: "body { color: red; }", Type: "css"},
+ },
+ contains: []string{"<style data-file=\"styles.css\">", "body { color: red; }"},
+ },
+ {
+ name: "inject js file",
+ html: "<html><head></head><body></body></html>",
+ files: []EmbeddedFile{
+ {Name: "app.js", Content: "console.log('hi');", Type: "js"},
+ },
+ contains: []string{"<script data-file=\"app.js\">", "console.log('hi');"},
+ },
+ {
+ name: "html without head tag",
+ html: "<html><body>content</body></html>",
+ files: []EmbeddedFile{
+ {Name: "data.json", Content: `{}`, Type: "json"},
+ },
+ contains: []string{"<head>", "</head>", "window.__FILES__"},
+ },
+ {
+ name: "plain html without tags",
+ html: "<div>content</div>",
+ files: []EmbeddedFile{
+ {Name: "data.json", Content: `{}`, Type: "json"},
+ },
+ contains: []string{"window.__FILES__", "<div>content</div>"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := injectFiles(tt.html, tt.files)
+ for _, s := range tt.contains {
+ if !strings.Contains(result, s) {
+ t.Errorf("expected result to contain %q, got: %s", s, result)
+ }
}
})
}
}
func TestOutputIframeToolSchema(t *testing.T) {
- // Verify the tool is properly configured
- if OutputIframeTool.Name != "output_iframe" {
- t.Errorf("expected name 'output_iframe', got %q", OutputIframeTool.Name)
+ workingDir := &MutableWorkingDir{}
+ workingDir.Set("/tmp")
+ tool := &OutputIframeTool{WorkingDir: workingDir}
+ llmTool := tool.Tool()
+
+ if llmTool.Name != "output_iframe" {
+ t.Errorf("expected name 'output_iframe', got %q", llmTool.Name)
}
- if OutputIframeTool.Run == nil {
+ if llmTool.Run == nil {
t.Error("expected Run function to be set")
}
- if len(OutputIframeTool.InputSchema) == 0 {
+ if len(llmTool.InputSchema) == 0 {
t.Error("expected InputSchema to be set")
}
+
+ // Verify schema contains files property
+ if !strings.Contains(string(llmTool.InputSchema), "files") {
+ t.Error("expected InputSchema to contain 'files' property")
+ }
}
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ jszip:
+ specifier: ^3.10.1
+ version: 3.10.1
monaco-editor:
specifier: ^0.44.0
version: 0.44.0
@@ -593,6 +596,9 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -867,6 +873,9 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -875,6 +884,9 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -975,6 +987,9 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
+ isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -1005,6 +1020,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1012,6 +1030,9 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1087,6 +1108,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1129,6 +1153,9 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1148,6 +1175,9 @@ packages:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -1171,6 +1201,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
+ safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+
safe-push-apply@1.0.0:
resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
engines: {node: '>= 0.4'}
@@ -1203,6 +1236,9 @@ packages:
resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
engines: {node: '>= 0.4'}
+ setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1250,6 +1286,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
@@ -1319,6 +1358,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -1793,6 +1835,8 @@ snapshots:
concat-map@0.0.1: {}
+ core-util-is@1.0.3: {}
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2215,6 +2259,8 @@ snapshots:
ignore@7.0.5: {}
+ immediate@3.0.6: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -2222,6 +2268,8 @@ snapshots:
imurmurhash@0.1.4: {}
+ inherits@2.0.4: {}
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -2334,6 +2382,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
+ isarray@1.0.0: {}
+
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -2366,6 +2416,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jszip@3.10.1:
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -2375,6 +2432,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lie@3.3.0:
+ dependencies:
+ immediate: 3.0.6
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -2460,6 +2521,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -2486,6 +2549,8 @@ snapshots:
prettier@3.7.4: {}
+ process-nextick-args@2.0.1: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -2506,6 +2571,16 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ readable-stream@2.3.8:
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -2544,6 +2619,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
+ safe-buffer@5.1.2: {}
+
safe-push-apply@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -2585,6 +2662,8 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ setimmediate@1.0.5: {}
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2668,6 +2747,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
+ string_decoder@1.1.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
strip-json-comments@3.1.1: {}
supports-color@7.2.0:
@@ -2755,6 +2838,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ util-deprecate@1.0.2: {}
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -1,9 +1,17 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
+import JSZip from "jszip";
import { LLMContent } from "../types";
+interface EmbeddedFile {
+ name: string;
+ path: string;
+ content: string;
+ type: string;
+}
+
interface OutputIframeToolProps {
// For tool_use (pending state)
- toolInput?: unknown; // { html: string, title?: string }
+ toolInput?: unknown; // { path: string, title?: string, files?: object }
isRunning?: boolean;
// For tool_result (completed state)
@@ -49,6 +57,22 @@ const HEIGHT_REPORTER_SCRIPT = `
const MIN_HEIGHT = 100;
const MAX_HEIGHT = 600;
+// Remove injected scripts/styles from HTML to get the original version for download
+function getOriginalHtml(html: string): string {
+ // Remove the window.__FILES__ script block
+ let result = html.replace(
+ /<script>\s*window\.__FILES__\s*=\s*window\.__FILES__\s*\|\|\s*\{\};[\s\S]*?<\/script>\s*/g,
+ "",
+ );
+ // Remove injected style tags
+ result = result.replace(/<style data-file="[^"]*">[\s\S]*?<\/style>\s*/g, "");
+ // Remove injected script tags
+ result = result.replace(/<script data-file="[^"]*">[\s\S]*?<\/script>\s*/g, "");
+ // Remove empty head tags that might have been added
+ result = result.replace(/<head>\s*<\/head>\s*/g, "");
+ return result;
+}
+
function OutputIframeTool({
toolInput,
isRunning,
@@ -88,13 +112,25 @@ function OutputIframeTool({
};
// Get display data - prefer from display prop, fall back to toolInput
- const getDisplayData = (): { html?: string; title?: string } => {
+ const getDisplayData = (): {
+ html?: string;
+ title?: string;
+ filename?: string;
+ files?: EmbeddedFile[];
+ } => {
// First try display prop (from tool result)
if (display && typeof display === "object" && display !== null) {
- const d = display as { html?: string; title?: string };
+ const d = display as {
+ html?: string;
+ title?: string;
+ filename?: string;
+ files?: EmbeddedFile[];
+ };
return {
html: typeof d.html === "string" ? d.html : undefined,
title: typeof d.title === "string" ? d.title : undefined,
+ filename: typeof d.filename === "string" ? d.filename : undefined,
+ files: Array.isArray(d.files) ? d.files : undefined,
};
}
// Fall back to toolInput
@@ -107,6 +143,9 @@ function OutputIframeTool({
const displayData = getDisplayData();
const title = displayData.title || "HTML Output";
const html = displayData.html;
+ const filename = displayData.filename || "output.html";
+ const files = displayData.files || [];
+ const hasMultipleFiles = files.length > 0;
// Inject height reporter script into HTML
const htmlWithHeightReporter = html
@@ -184,7 +223,52 @@ function OutputIframeTool({
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
+ // Download files - single HTML or zip with all files
+ const handleDownload = async (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (!html) return;
+
+ if (hasMultipleFiles) {
+ // Create a zip file with all files
+ const zip = new JSZip();
+
+ // Add the original HTML (without injected content)
+ const originalHtml = getOriginalHtml(html);
+ zip.file(filename, originalHtml);
+
+ // Add all embedded files
+ for (const file of files) {
+ zip.file(file.path || file.name, file.content);
+ }
+
+ // Generate and download the zip
+ const zipBlob = await zip.generateAsync({ type: "blob" });
+ const url = URL.createObjectURL(zipBlob);
+ const a = document.createElement("a");
+ a.href = url;
+ // Use the HTML filename without extension for the zip name
+ const zipName = filename.replace(/\.[^.]+$/, "") + ".zip";
+ a.download = zipName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ } else {
+ // Single file download
+ const blob = new Blob([html], { type: "text/html" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+ };
+
const isComplete = !isRunning && toolResult !== undefined;
+ const downloadLabel = hasMultipleFiles ? "Download ZIP" : "Download HTML";
return (
<div
@@ -200,27 +284,50 @@ function OutputIframeTool({
</div>
<div className="output-iframe-tool-actions">
{isComplete && !hasError && html && (
- <button
- className="output-iframe-tool-open-btn"
- onClick={handleOpenInNewTab}
- aria-label="Open in new tab"
- title="Open in new tab"
- >
- <svg
- width="14"
- height="14"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
+ <>
+ <button
+ className="output-iframe-tool-download-btn"
+ onClick={handleDownload}
+ aria-label={downloadLabel}
+ title={downloadLabel}
+ >
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+ <polyline points="7 10 12 15 17 10" />
+ <line x1="12" y1="15" x2="12" y2="3" />
+ </svg>
+ </button>
+ <button
+ className="output-iframe-tool-open-btn"
+ onClick={handleOpenInNewTab}
+ aria-label="Open in new tab"
+ title="Open in new tab"
>
- <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
- <polyline points="15 3 21 3 21 9" />
- <line x1="10" y1="14" x2="21" y2="3" />
- </svg>
- </button>
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
+ <polyline points="15 3 21 3 21 9" />
+ <line x1="10" y1="14" x2="21" y2="3" />
+ </svg>
+ </button>
+ </>
)}
<button
className="output-iframe-tool-toggle"