output_iframe.go

  1package claudetool
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9
 10	"shelley.exe.dev/llm"
 11)
 12
 13// OutputIframeTool displays sandboxed HTML content to the user.
 14// It requires a MutableWorkingDir to resolve relative file paths.
 15type OutputIframeTool struct {
 16	WorkingDir *MutableWorkingDir
 17}
 18
 19func (t *OutputIframeTool) Tool() *llm.Tool {
 20	return &llm.Tool{
 21		Name:        outputIframeName,
 22		Description: outputIframeDescription,
 23		InputSchema: llm.MustSchema(outputIframeInputSchema),
 24		Run:         t.Run,
 25	}
 26}
 27
 28const (
 29	outputIframeName        = "output_iframe"
 30	outputIframeDescription = `Display HTML content to the user in a sandboxed iframe.
 31
 32Use this tool for visualizations like charts, graphs, and HTML demos that the user should see.
 33The HTML will be rendered in a secure sandbox with scripts enabled but isolated from the parent page.
 34
 35Do NOT use this tool for:
 36- Regular text responses (use normal messages instead)
 37- File operations (use patch or bash)
 38- Simple data display (just describe it in text)
 39
 40Good uses:
 41- Vega-Lite or other chart library visualizations  
 42- HTML/CSS demonstrations
 43- Interactive widgets or mini-apps
 44- SVG graphics
 45
 46The HTML should be self-contained. You can include inline <script> and <style> tags.
 47External resources can be loaded via CDN (e.g., https://cdn.jsdelivr.net/).
 48
 49For visualizations that need external data files (JSON, CSV, etc.), use the 'files' parameter
 50to bundle them. They will be injected into the page and accessible via window.__FILES__['filename'].`
 51
 52	outputIframeInputSchema = `
 53{
 54  "type": "object",
 55  "required": ["path"],
 56  "properties": {
 57    "path": {
 58      "type": "string",
 59      "description": "Path to the HTML file to display. Relative paths are resolved from the working directory."
 60    },
 61    "title": {
 62      "type": "string", 
 63      "description": "Optional title describing the visualization"
 64    },
 65    "files": {
 66      "type": "object",
 67      "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.",
 68      "additionalProperties": {
 69        "type": "string"
 70      }
 71    }
 72  }
 73}
 74`
 75)
 76
 77// EmbeddedFile represents a file bundled with the HTML.
 78type EmbeddedFile struct {
 79	Name    string `json:"name"`
 80	Path    string `json:"path"`
 81	Content string `json:"content"`
 82	Type    string `json:"type"` // "json", "css", "js", "text"
 83}
 84
 85// OutputIframeDisplay is the data passed to the UI for rendering.
 86type OutputIframeDisplay struct {
 87	Type     string         `json:"type"`
 88	HTML     string         `json:"html"`
 89	Title    string         `json:"title,omitempty"`
 90	Filename string         `json:"filename,omitempty"`
 91	Files    []EmbeddedFile `json:"files,omitempty"`
 92}
 93
 94// detectFileType guesses the file type from the filename.
 95func detectFileType(filename string) string {
 96	ext := strings.ToLower(filepath.Ext(filename))
 97	switch ext {
 98	case ".json":
 99		return "json"
100	case ".css":
101		return "css"
102	case ".js":
103		return "js"
104	case ".csv":
105		return "csv"
106	default:
107		return "text"
108	}
109}
110
111// injectFiles modifies the HTML to make bundled files accessible.
112// For JSON/text/csv files: adds a script that populates window.__FILES__
113// For CSS files: injects as <style> tags
114// For JS files: injects as <script> tags
115func injectFiles(html string, files []EmbeddedFile) string {
116	if len(files) == 0 {
117		return html
118	}
119
120	var jsFiles []EmbeddedFile
121	var cssFiles []EmbeddedFile
122	var dataFiles []EmbeddedFile
123
124	for _, f := range files {
125		switch f.Type {
126		case "css":
127			cssFiles = append(cssFiles, f)
128		case "js":
129			jsFiles = append(jsFiles, f)
130		default:
131			dataFiles = append(dataFiles, f)
132		}
133	}
134
135	var injection strings.Builder
136
137	// Inject CSS files as style tags
138	for _, f := range cssFiles {
139		injection.WriteString("<style data-file=\"")
140		injection.WriteString(f.Name)
141		injection.WriteString("\">\n")
142		injection.WriteString(f.Content)
143		injection.WriteString("\n</style>\n")
144	}
145
146	// Inject data files as window.__FILES__
147	if len(dataFiles) > 0 {
148		injection.WriteString("<script>\nwindow.__FILES__ = window.__FILES__ || {};\n")
149		for _, f := range dataFiles {
150			// Escape the content for JavaScript string
151			escaped := escapeJSString(f.Content)
152			injection.WriteString("window.__FILES__[\"")
153			injection.WriteString(f.Name)
154			injection.WriteString("\"] = \"")
155			injection.WriteString(escaped)
156			injection.WriteString("\";\n")
157		}
158		injection.WriteString("</script>\n")
159	}
160
161	// Inject JS files as script tags
162	for _, f := range jsFiles {
163		injection.WriteString("<script data-file=\"")
164		injection.WriteString(f.Name)
165		injection.WriteString("\">\n")
166		injection.WriteString(f.Content)
167		injection.WriteString("\n</script>\n")
168	}
169
170	// Insert after <head> or at the beginning
171	injectionStr := injection.String()
172	if idx := strings.Index(strings.ToLower(html), "<head>"); idx != -1 {
173		return html[:idx+6] + "\n" + injectionStr + html[idx+6:]
174	}
175	if idx := strings.Index(strings.ToLower(html), "<html>"); idx != -1 {
176		return html[:idx+6] + "\n<head>\n" + injectionStr + "</head>\n" + html[idx+6:]
177	}
178	// No head or html tag, just prepend
179	return injectionStr + html
180}
181
182// escapeJSString escapes a string for use in a JavaScript string literal.
183func escapeJSString(s string) string {
184	var b strings.Builder
185	for _, r := range s {
186		switch r {
187		case '\\':
188			b.WriteString("\\\\")
189		case '"':
190			b.WriteString("\\\"")
191		case '\n':
192			b.WriteString("\\n")
193		case '\r':
194			b.WriteString("\\r")
195		case '\t':
196			b.WriteString("\\t")
197		default:
198			b.WriteRune(r)
199		}
200	}
201	return b.String()
202}
203
204func (t *OutputIframeTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
205	var input struct {
206		Path  string            `json:"path"`
207		Title string            `json:"title"`
208		Files map[string]string `json:"files"`
209	}
210	if err := json.Unmarshal(m, &input); err != nil {
211		return llm.ErrorToolOut(err)
212	}
213
214	if input.Path == "" {
215		return llm.ErrorfToolOut("path is required")
216	}
217
218	// Resolve the path relative to working directory
219	path := input.Path
220	if !filepath.IsAbs(path) {
221		path = filepath.Join(t.WorkingDir.Get(), path)
222	}
223
224	// Read the main HTML file
225	data, err := os.ReadFile(path)
226	if err != nil {
227		return llm.ErrorfToolOut("failed to read file: %v", err)
228	}
229
230	// Read additional files
231	var embeddedFiles []EmbeddedFile
232	for name, filePath := range input.Files {
233		// Resolve relative paths
234		if !filepath.IsAbs(filePath) {
235			filePath = filepath.Join(t.WorkingDir.Get(), filePath)
236		}
237		content, err := os.ReadFile(filePath)
238		if err != nil {
239			return llm.ErrorfToolOut("failed to read file %q: %v", name, err)
240		}
241		embeddedFiles = append(embeddedFiles, EmbeddedFile{
242			Name:    name,
243			Path:    input.Files[name], // Original path for download
244			Content: string(content),
245			Type:    detectFileType(name),
246		})
247	}
248
249	// Inject files into the HTML for iframe display
250	html := injectFiles(string(data), embeddedFiles)
251
252	display := OutputIframeDisplay{
253		Type:     "output_iframe",
254		HTML:     html,
255		Title:    input.Title,
256		Filename: filepath.Base(input.Path),
257		Files:    embeddedFiles,
258	}
259
260	return llm.ToolOut{
261		LLMContent: llm.TextContent("displayed"),
262		Display:    display,
263	}
264}