diff --git a/backend/Dockerfile b/backend/Dockerfile index 67b5b81..f945286 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,8 +18,8 @@ RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/serv # Final stage FROM alpine:latest -# curl for web fetching, ca-certificates for HTTPS -RUN apk --no-cache add ca-certificates curl +# curl for web fetching, ca-certificates for HTTPS, python3 for tool execution +RUN apk --no-cache add ca-certificates curl python3 WORKDIR /app diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 296f18e..e8d461a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,7 @@ import ( ) // Version is set at build time via -ldflags, or defaults to dev -var Version = "0.3.0" +var Version = "0.4.0" func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 9a6f989..b6d90e4 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -66,6 +66,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string, appVersion string) // IP-based geolocation (fallback when browser geolocation fails) v1.GET("/location", IPGeolocationHandler()) + // Tool execution (for Python tools) + v1.POST("/tools/execute", ExecuteToolHandler()) + // Model registry routes (cached models from ollama.com) models := v1.Group("/models") { diff --git a/backend/internal/api/tools.go b/backend/internal/api/tools.go new file mode 100644 index 0000000..79a23f2 --- /dev/null +++ b/backend/internal/api/tools.go @@ -0,0 +1,174 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "time" + + "github.com/gin-gonic/gin" +) + +// ExecuteToolRequest represents a tool execution request +type ExecuteToolRequest struct { + Language string `json:"language" binding:"required,oneof=python javascript"` + Code string `json:"code" binding:"required"` + Args map[string]interface{} `json:"args"` + Timeout int `json:"timeout"` // seconds, default 30 +} + +// ExecuteToolResponse represents the tool execution response +type ExecuteToolResponse struct { + Success bool `json:"success"` + Result interface{} `json:"result,omitempty"` + Error string `json:"error,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` +} + +// MaxOutputSize is the maximum size of tool output (100KB) +const MaxOutputSize = 100 * 1024 + +// ExecuteToolHandler handles tool execution requests +func ExecuteToolHandler() gin.HandlerFunc { + return func(c *gin.Context) { + var req ExecuteToolRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ExecuteToolResponse{ + Success: false, + Error: "Invalid request: " + err.Error(), + }) + return + } + + // Default timeout + timeout := req.Timeout + if timeout <= 0 || timeout > 60 { + timeout = 30 + } + + var resp ExecuteToolResponse + + switch req.Language { + case "python": + resp = executePython(req.Code, req.Args, timeout) + case "javascript": + // JavaScript execution not supported on backend (runs in browser) + resp = ExecuteToolResponse{ + Success: false, + Error: "JavaScript tools should be executed in the browser", + } + default: + resp = ExecuteToolResponse{ + Success: false, + Error: "Unsupported language: " + req.Language, + } + } + + c.JSON(http.StatusOK, resp) + } +} + +// executePython executes Python code with the given arguments +func executePython(code string, args map[string]interface{}, timeout int) ExecuteToolResponse { + // Create a wrapper script that reads args from stdin + wrapperScript := ` +import json +import sys + +# Read args from stdin +args = json.loads(sys.stdin.read()) + +# Execute user code +` + code + + // Create temp file for the script + tmpFile, err := os.CreateTemp("", "tool-*.py") + if err != nil { + return ExecuteToolResponse{ + Success: false, + Error: "Failed to create temp file: " + err.Error(), + } + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(wrapperScript); err != nil { + return ExecuteToolResponse{ + Success: false, + Error: "Failed to write script: " + err.Error(), + } + } + tmpFile.Close() + + // Marshal args to JSON for stdin + argsJSON, err := json.Marshal(args) + if err != nil { + return ExecuteToolResponse{ + Success: false, + Error: "Failed to serialize args: " + err.Error(), + } + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + defer cancel() + + // Execute Python (using exec.Command, not shell) + cmd := exec.CommandContext(ctx, "python3", tmpFile.Name()) + cmd.Stdin = bytes.NewReader(argsJSON) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + // Check for timeout + if ctx.Err() == context.DeadlineExceeded { + return ExecuteToolResponse{ + Success: false, + Error: "Execution timed out after " + string(rune(timeout)) + " seconds", + Stderr: truncateOutput(stderr.String()), + } + } + + // Truncate output if needed + stdoutStr := truncateOutput(stdout.String()) + stderrStr := truncateOutput(stderr.String()) + + if err != nil { + return ExecuteToolResponse{ + Success: false, + Error: "Execution failed: " + err.Error(), + Stdout: stdoutStr, + Stderr: stderrStr, + } + } + + // Try to parse stdout as JSON + var result interface{} + if stdoutStr != "" { + if err := json.Unmarshal([]byte(stdoutStr), &result); err != nil { + // If not valid JSON, return as string + result = stdoutStr + } + } + + return ExecuteToolResponse{ + Success: true, + Result: result, + Stdout: stdoutStr, + Stderr: stderrStr, + } +} + +// truncateOutput truncates output to MaxOutputSize +func truncateOutput(s string) string { + if len(s) > MaxOutputSize { + return s[:MaxOutputSize] + "\n... (output truncated)" + } + return s +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35ed48f..9b4d235 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,17 +1,22 @@ { "name": "vessel", - "version": "0.1.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vessel", - "version": "0.1.0", + "version": "0.3.0", "dependencies": { + "@codemirror/lang-javascript": "^6.2.3", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-python": "^6.1.7", + "@codemirror/theme-one-dark": "^6.1.2", "@skeletonlabs/skeleton": "^2.10.0", "@skeletonlabs/tw-plugin": "^0.4.0", "@sveltejs/adapter-node": "^5.4.0", "@types/dompurify": "^3.0.5", + "codemirror": "^6.0.1", "dexie": "^4.0.10", "dompurify": "^3.2.0", "marked": "^15.0.0", @@ -110,6 +115,137 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", + "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", + "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz", + "integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.8.tgz", + "integrity": "sha512-1rASYd9Z/mE3tkbC9wInRlCNyCkSn+nLsiQKZhEDUUJiUfs/5FHDpCUDaQpoTIaNGeDc6/bhaEAyLmeEucEFPw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "dev": true, @@ -698,6 +834,69 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.5.tgz", + "integrity": "sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "0.1.88", "license": "MIT", @@ -2049,6 +2248,21 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "license": "MIT", @@ -2075,6 +2289,12 @@ "node": ">= 0.6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/css-tree": { "version": "3.1.0", "dev": true, @@ -3362,6 +3582,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "license": "MIT", @@ -3961,6 +4187,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index fa35040..d51842f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "vessel", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -32,7 +32,12 @@ "vitest": "^4.0.16" }, "dependencies": { + "@codemirror/lang-javascript": "^6.2.3", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/lang-python": "^6.1.7", + "@codemirror/theme-one-dark": "^6.1.2", "@skeletonlabs/skeleton": "^2.10.0", + "codemirror": "^6.0.1", "@skeletonlabs/tw-plugin": "^0.4.0", "@sveltejs/adapter-node": "^5.4.0", "@types/dompurify": "^3.0.5", diff --git a/frontend/src/lib/components/tools/CodeEditor.svelte b/frontend/src/lib/components/tools/CodeEditor.svelte new file mode 100644 index 0000000..949e0b0 --- /dev/null +++ b/frontend/src/lib/components/tools/CodeEditor.svelte @@ -0,0 +1,127 @@ + + +
+ + diff --git a/frontend/src/lib/components/tools/ToolDocs.svelte b/frontend/src/lib/components/tools/ToolDocs.svelte new file mode 100644 index 0000000..d6469a4 --- /dev/null +++ b/frontend/src/lib/components/tools/ToolDocs.svelte @@ -0,0 +1,117 @@ + + +{#if isOpen} +
+
+

+ {language === 'javascript' ? 'JavaScript' : 'Python'} Tool Guide +

+ {#if onclose} + + {/if} +
+ +
+ {#if language === 'javascript'} + +
+
Arguments
+

Access parameters via the args object:

+
const name = args.name;
+const count = args.count || 10;
+
+ +
+
Return Value
+

Return any JSON-serializable value:

+
return {'{'}
+  success: true,
+  data: result
+{'}'};
+
+ +
+
Async/Await
+

Full async support - use await for API calls:

+
const res = await fetch(args.url);
+const data = await res.json();
+return data;
+
+ +
+
Error Handling
+

Throw errors to signal failures:

+
if (!args.required_param) {'{'}
+  throw new Error('Missing required param');
+{'}'}
+
+ {:else} + +
+
Arguments
+

Access parameters via the args dict:

+
name = args.get('name')
+count = args.get('count', 10)
+
+ +
+
Return Value
+

Print JSON to stdout (import json first):

+
import json
+
+result = {'{'}'success': True, 'data': data{'}'}
+print(json.dumps(result))
+
+ +
+
Available Modules
+

Python standard library is available:

+
import json, math, re
+import hashlib, base64
+import urllib.request
+from collections import Counter
+
+ +
+
Error Handling
+

Print error JSON or raise exceptions:

+
try:
+    # risky operation
+except Exception as e:
+    print(json.dumps({'{'}'error': str(e){'}'})
+
+ {/if} + +
+
Tips
+
    +
  • Tools run with a 30-second timeout
  • +
  • Large outputs are truncated at 100KB
  • +
  • Network requests are allowed
  • +
  • Use descriptive error messages for debugging
  • +
+
+
+
+{/if} diff --git a/frontend/src/lib/components/tools/ToolEditor.svelte b/frontend/src/lib/components/tools/ToolEditor.svelte index a7f953c..afff102 100644 --- a/frontend/src/lib/components/tools/ToolEditor.svelte +++ b/frontend/src/lib/components/tools/ToolEditor.svelte @@ -5,6 +5,10 @@ import { toolsState } from '$lib/stores'; import type { CustomTool, JSONSchema, JSONSchemaProperty, ToolImplementation } from '$lib/tools'; + import { getTemplatesByLanguage, type ToolTemplate } from '$lib/tools'; + import CodeEditor from './CodeEditor.svelte'; + import ToolDocs from './ToolDocs.svelte'; + import ToolTester from './ToolTester.svelte'; interface Props { isOpen: boolean; @@ -15,21 +19,62 @@ const { isOpen, editingTool = null, onClose, onSave }: Props = $props(); + // Default code templates + const defaultJsCode = `// Arguments are available as \`args\` object +// Return the result +return { message: "Hello from custom tool!" };`; + + const defaultPythonCode = `# Arguments available as \`args\` dict +# Print JSON result to stdout +import json + +result = {"message": f"Hello, {args.get('name', 'World')}!"} +print(json.dumps(result))`; + // Form state let name = $state(''); let description = $state(''); let implementation = $state('javascript'); - let code = $state('// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };'); + let code = $state(defaultJsCode); let endpoint = $state(''); let httpMethod = $state<'GET' | 'POST'>('POST'); let enabled = $state(true); + // Track previous implementation for code switching + let prevImplementation = $state('javascript'); + // Parameters state (simplified - array of parameter definitions) let parameters = $state>([]); // Validation let errors = $state>({}); + // UI state + let showDocs = $state(false); + let showTemplates = $state(false); + let showTest = $state(false); + + // Get templates for current language + const currentTemplates = $derived( + implementation === 'javascript' || implementation === 'python' + ? getTemplatesByLanguage(implementation) + : [] + ); + + function applyTemplate(template: ToolTemplate): void { + name = template.name.toLowerCase().replace(/[^a-z0-9]+/g, '_'); + description = template.description; + code = template.code; + // Convert parameters from template + parameters = Object.entries(template.parameters.properties ?? {}).map(([paramName, prop]) => ({ + name: paramName, + type: prop.type, + description: prop.description ?? '', + required: template.parameters.required?.includes(paramName) ?? false + })); + showTemplates = false; + } + // Reset form when modal opens or editing tool changes $effect(() => { if (isOpen) { @@ -59,13 +104,32 @@ name = ''; description = ''; implementation = 'javascript'; - code = '// Arguments are available as `args` object\n// Return the result\nreturn { message: "Hello from custom tool!" };'; + prevImplementation = 'javascript'; + code = defaultJsCode; endpoint = ''; httpMethod = 'POST'; enabled = true; parameters = []; } + // Switch to default code when implementation changes (unless editing) + $effect(() => { + if (!editingTool && implementation !== prevImplementation) { + // Only switch code if it's still the default for the previous type + const isDefaultJs = code === defaultJsCode || code.trim() === ''; + const isDefaultPy = code === defaultPythonCode; + + if (isDefaultJs || isDefaultPy || code.trim() === '') { + if (implementation === 'python') { + code = defaultPythonCode; + } else if (implementation === 'javascript') { + code = defaultJsCode; + } + } + prevImplementation = implementation; + } + }); + function addParameter(): void { parameters = [...parameters, { name: '', type: 'string', description: '', required: false }]; } @@ -93,8 +157,8 @@ newErrors.description = 'Description is required'; } - if (implementation === 'javascript' && !code.trim()) { - newErrors.code = 'JavaScript code is required'; + if ((implementation === 'javascript' || implementation === 'python') && !code.trim()) { + newErrors.code = `${implementation === 'javascript' ? 'JavaScript' : 'Python'} code is required`; } if (implementation === 'http' && !endpoint.trim()) { @@ -144,7 +208,7 @@ description: description.trim(), parameters: buildParameterSchema(), implementation, - code: implementation === 'javascript' ? code : undefined, + code: (implementation === 'javascript' || implementation === 'python') ? code : undefined, endpoint: implementation === 'http' ? endpoint : undefined, httpMethod: implementation === 'http' ? httpMethod : undefined, enabled, @@ -290,7 +354,7 @@
-
+
+
- - {#if implementation === 'javascript'} + + {#if implementation === 'javascript' || implementation === 'python'}
- -

- Arguments are passed as an args object. Return the result. +

+ +
+ +
+ + {#if showTemplates && currentTemplates.length > 0} +
+
+ {#each currentTemplates as template (template.id)} + + {/each} +
+
+ {/if} +
+ + +
+
+

+ {#if implementation === 'javascript'} + Arguments are passed as an args object. Return the result. + {:else} + Arguments are available as args dict. Print JSON result to stdout. + {/if}

- + + + showDocs = false} + /> + +
+ +
{#if errors.code}

{errors.code}

{/if} + + + + + + showTest = false} + />
{/if} diff --git a/frontend/src/lib/components/tools/ToolTester.svelte b/frontend/src/lib/components/tools/ToolTester.svelte new file mode 100644 index 0000000..d0a1b8f --- /dev/null +++ b/frontend/src/lib/components/tools/ToolTester.svelte @@ -0,0 +1,220 @@ + + +{#if isOpen} +
+
+

+ + + + Test Tool +

+ {#if onclose} + + {/if} +
+ +
+ +
+ + +
+ + + + + + {#if testResult} +
+ +
+ {#if testResult.success} +
+ + + + Success +
+
{formatResult(testResult.result)}
+ {:else} +
+ + + + Error +
+
{testResult.error}
+ {/if} +
+
+ {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/tools/index.ts b/frontend/src/lib/components/tools/index.ts index a7246ca..edadbcf 100644 --- a/frontend/src/lib/components/tools/index.ts +++ b/frontend/src/lib/components/tools/index.ts @@ -2,4 +2,7 @@ * Tools components exports */ +export { default as CodeEditor } from './CodeEditor.svelte'; +export { default as ToolDocs } from './ToolDocs.svelte'; export { default as ToolEditor } from './ToolEditor.svelte'; +export { default as ToolTester } from './ToolTester.svelte'; diff --git a/frontend/src/lib/tools/executor.ts b/frontend/src/lib/tools/executor.ts index 1347cbe..4b21e65 100644 --- a/frontend/src/lib/tools/executor.ts +++ b/frontend/src/lib/tools/executor.ts @@ -41,6 +41,45 @@ async function executeJavaScriptTool(tool: CustomTool, args: Record): Promise { + if (!tool.code) { + throw new Error('Python tool has no code'); + } + + try { + const response = await fetch('/api/v1/tools/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + language: 'python', + code: tool.code, + args, + timeout: 30 + }) + }); + + if (!response.ok) { + throw new Error(`Backend error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Python execution failed'); + } + + return data.result; + } catch (error) { + throw new Error(`Python tool error: ${error instanceof Error ? error.message : String(error)}`); + } +} + /** * Execute a custom HTTP tool */ @@ -168,6 +207,8 @@ export async function executeCustomTool( switch (tool.implementation) { case 'javascript': return executeJavaScriptTool(tool, args); + case 'python': + return executePythonTool(tool, args); case 'http': return executeHttpTool(tool, args); default: diff --git a/frontend/src/lib/tools/index.ts b/frontend/src/lib/tools/index.ts index eba6116..13896ec 100644 --- a/frontend/src/lib/tools/index.ts +++ b/frontend/src/lib/tools/index.ts @@ -21,3 +21,10 @@ export { defaultToolConfig, type ToolConfig } from './config.js'; +export { + toolTemplates, + getTemplatesByLanguage, + getTemplatesByCategory, + getTemplateById, + type ToolTemplate +} from './templates.js'; diff --git a/frontend/src/lib/tools/templates.ts b/frontend/src/lib/tools/templates.ts new file mode 100644 index 0000000..313250d --- /dev/null +++ b/frontend/src/lib/tools/templates.ts @@ -0,0 +1,352 @@ +/** + * Tool templates - Starter templates for custom tools + */ + +import type { JSONSchema, ToolImplementation } from './types'; + +export interface ToolTemplate { + id: string; + name: string; + description: string; + category: 'api' | 'data' | 'utility' | 'integration'; + language: ToolImplementation; + code: string; + parameters: JSONSchema; +} + +export const toolTemplates: ToolTemplate[] = [ + // JavaScript Templates + { + id: 'js-api-fetch', + name: 'API Request', + description: 'Fetch data from an external REST API', + category: 'api', + language: 'javascript', + code: `// Fetch data from an API endpoint +const response = await fetch(args.url, { + method: args.method || 'GET', + headers: { + 'Content-Type': 'application/json', + ...(args.headers || {}) + }, + ...(args.body ? { body: JSON.stringify(args.body) } : {}) +}); + +if (!response.ok) { + throw new Error(\`HTTP \${response.status}: \${response.statusText}\`); +} + +return await response.json();`, + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'The API endpoint URL' }, + method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' }, + headers: { type: 'object', description: 'Additional headers' }, + body: { type: 'object', description: 'Request body for POST/PUT' } + }, + required: ['url'] + } + }, + { + id: 'js-json-transform', + name: 'JSON Transform', + description: 'Transform and filter JSON data', + category: 'data', + language: 'javascript', + code: `// Transform JSON data +const data = args.data; +const fields = args.fields || Object.keys(data[0] || data); + +// Handle both arrays and single objects +const items = Array.isArray(data) ? data : [data]; + +const result = items.map(item => { + const filtered = {}; + for (const field of fields) { + if (field in item) { + filtered[field] = item[field]; + } + } + return filtered; +}); + +return Array.isArray(data) ? result : result[0];`, + parameters: { + type: 'object', + properties: { + data: { type: 'object', description: 'JSON data to transform' }, + fields: { type: 'array', description: 'Fields to keep (optional)' } + }, + required: ['data'] + } + }, + { + id: 'js-string-utils', + name: 'String Utilities', + description: 'Common string manipulation operations', + category: 'utility', + language: 'javascript', + code: `// String manipulation utilities +const text = args.text; +const operation = args.operation; + +switch (operation) { + case 'uppercase': + return text.toUpperCase(); + case 'lowercase': + return text.toLowerCase(); + case 'capitalize': + return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); + case 'reverse': + return text.split('').reverse().join(''); + case 'word_count': + return { count: text.split(/\\s+/).filter(w => w).length }; + case 'char_count': + return { count: text.length }; + case 'slug': + return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + default: + return { text, error: 'Unknown operation' }; +}`, + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Input text to process' }, + operation: { type: 'string', description: 'Operation: uppercase, lowercase, capitalize, reverse, word_count, char_count, slug' } + }, + required: ['text', 'operation'] + } + }, + { + id: 'js-date-utils', + name: 'Date Utilities', + description: 'Date formatting and calculations', + category: 'utility', + language: 'javascript', + code: `// Date utilities +const date = args.date ? new Date(args.date) : new Date(); +const format = args.format || 'iso'; + +const formatDate = (d, fmt) => { + const pad = n => String(n).padStart(2, '0'); + + switch (fmt) { + case 'iso': + return d.toISOString(); + case 'date': + return d.toLocaleDateString(); + case 'time': + return d.toLocaleTimeString(); + case 'unix': + return Math.floor(d.getTime() / 1000); + case 'relative': + const diff = Date.now() - d.getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return \`\${mins} minutes ago\`; + const hours = Math.floor(mins / 60); + if (hours < 24) return \`\${hours} hours ago\`; + return \`\${Math.floor(hours / 24)} days ago\`; + default: + return d.toISOString(); + } +}; + +return { + formatted: formatDate(date, format), + timestamp: date.getTime(), + iso: date.toISOString() +};`, + parameters: { + type: 'object', + properties: { + date: { type: 'string', description: 'Date string or timestamp (default: now)' }, + format: { type: 'string', description: 'Format: iso, date, time, unix, relative' } + } + } + }, + + // Python Templates + { + id: 'py-api-fetch', + name: 'API Request (Python)', + description: 'Fetch data from an external REST API using Python', + category: 'api', + language: 'python', + code: `# Fetch data from an API endpoint +import json +import urllib.request +import urllib.error + +url = args.get('url') +method = args.get('method', 'GET') +headers = args.get('headers', {}) +body = args.get('body') + +req = urllib.request.Request(url, method=method) +req.add_header('Content-Type', 'application/json') +for key, value in headers.items(): + req.add_header(key, value) + +data = json.dumps(body).encode() if body else None + +try: + with urllib.request.urlopen(req, data=data) as response: + result = json.loads(response.read().decode()) + print(json.dumps(result)) +except urllib.error.HTTPError as e: + print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))`, + parameters: { + type: 'object', + properties: { + url: { type: 'string', description: 'The API endpoint URL' }, + method: { type: 'string', description: 'HTTP method (GET, POST, etc.)' }, + headers: { type: 'object', description: 'Additional headers' }, + body: { type: 'object', description: 'Request body for POST/PUT' } + }, + required: ['url'] + } + }, + { + id: 'py-data-analysis', + name: 'Data Analysis (Python)', + description: 'Basic statistical analysis of numeric data', + category: 'data', + language: 'python', + code: `# Basic data analysis +import json +import math + +data = args.get('data', []) +if not data: + print(json.dumps({"error": "No data provided"})) +else: + n = len(data) + total = sum(data) + mean = total / n + + sorted_data = sorted(data) + mid = n // 2 + median = sorted_data[mid] if n % 2 else (sorted_data[mid-1] + sorted_data[mid]) / 2 + + variance = sum((x - mean) ** 2 for x in data) / n + std_dev = math.sqrt(variance) + + result = { + "count": n, + "sum": total, + "mean": round(mean, 4), + "median": median, + "min": min(data), + "max": max(data), + "std_dev": round(std_dev, 4), + "variance": round(variance, 4) + } + print(json.dumps(result))`, + parameters: { + type: 'object', + properties: { + data: { type: 'array', description: 'Array of numbers to analyze' } + }, + required: ['data'] + } + }, + { + id: 'py-text-analysis', + name: 'Text Analysis (Python)', + description: 'Analyze text for word frequency, sentiment indicators', + category: 'data', + language: 'python', + code: `# Text analysis +import json +import re +from collections import Counter + +text = args.get('text', '') +top_n = args.get('top_n', 10) + +# Tokenize and count +words = re.findall(r'\\b\\w+\\b', text.lower()) +word_freq = Counter(words) + +# Basic stats +sentences = re.split(r'[.!?]+', text) +sentences = [s.strip() for s in sentences if s.strip()] + +result = { + "word_count": len(words), + "unique_words": len(word_freq), + "sentence_count": len(sentences), + "avg_word_length": round(sum(len(w) for w in words) / len(words), 2) if words else 0, + "top_words": dict(word_freq.most_common(top_n)), + "char_count": len(text), + "char_count_no_spaces": len(text.replace(' ', '')) +} +print(json.dumps(result))`, + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text to analyze' }, + top_n: { type: 'number', description: 'Number of top words to return (default: 10)' } + }, + required: ['text'] + } + }, + { + id: 'py-hash-encode', + name: 'Hash & Encode (Python)', + description: 'Hash strings and encode/decode base64', + category: 'utility', + language: 'python', + code: `# Hash and encoding utilities +import json +import hashlib +import base64 + +text = args.get('text', '') +operation = args.get('operation', 'md5') + +result = {} + +if operation == 'md5': + result['hash'] = hashlib.md5(text.encode()).hexdigest() +elif operation == 'sha256': + result['hash'] = hashlib.sha256(text.encode()).hexdigest() +elif operation == 'sha512': + result['hash'] = hashlib.sha512(text.encode()).hexdigest() +elif operation == 'base64_encode': + result['encoded'] = base64.b64encode(text.encode()).decode() +elif operation == 'base64_decode': + try: + result['decoded'] = base64.b64decode(text.encode()).decode() + except Exception as e: + result['error'] = str(e) +else: + result['error'] = f'Unknown operation: {operation}' + +result['operation'] = operation +result['input_length'] = len(text) + +print(json.dumps(result))`, + parameters: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text to process' }, + operation: { type: 'string', description: 'Operation: md5, sha256, sha512, base64_encode, base64_decode' } + }, + required: ['text', 'operation'] + } + } +]; + +export function getTemplatesByLanguage(language: ToolImplementation): ToolTemplate[] { + return toolTemplates.filter(t => t.language === language); +} + +export function getTemplatesByCategory(category: ToolTemplate['category']): ToolTemplate[] { + return toolTemplates.filter(t => t.category === category); +} + +export function getTemplateById(id: string): ToolTemplate | undefined { + return toolTemplates.find(t => t.id === id); +} diff --git a/frontend/src/lib/tools/types.ts b/frontend/src/lib/tools/types.ts index 6bf15de..ba38f69 100644 --- a/frontend/src/lib/tools/types.ts +++ b/frontend/src/lib/tools/types.ts @@ -54,7 +54,7 @@ export interface ToolResult { } /** Tool implementation type */ -export type ToolImplementation = 'builtin' | 'javascript' | 'http'; +export type ToolImplementation = 'builtin' | 'javascript' | 'python' | 'http'; /** Custom tool configuration */ export interface CustomTool { @@ -63,7 +63,7 @@ export interface CustomTool { description: string; parameters: JSONSchema; implementation: ToolImplementation; - /** JavaScript code for 'javascript' implementation */ + /** Code for 'javascript' or 'python' implementation */ code?: string; /** HTTP endpoint for 'http' implementation */ endpoint?: string;