feat(tools): enhanced custom tool creation with CodeMirror, Python support, and testing

- Add CodeMirror editor with syntax highlighting for JavaScript and Python
- Add 8 starter templates (4 JS, 4 Python) for common tool patterns
- Add inline documentation panel with language-specific guidance
- Add tool testing UI to run tools with sample inputs before saving
- Add Python tool execution via backend API with 30s timeout
- Add POST /api/v1/tools/execute endpoint for backend tool execution
- Update Dockerfile to include Python 3 for tool execution
- Bump version to 0.4.0
This commit is contained in:
2026-01-02 20:15:40 +01:00
parent 5572cd3a0d
commit 9e19544653
15 changed files with 1459 additions and 25 deletions

View File

@@ -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

View File

@@ -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 != "" {

View File

@@ -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")
{

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView, basicSetup } from 'codemirror';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorState, Compartment } from '@codemirror/state';
interface Props {
value: string;
language?: 'javascript' | 'python' | 'json';
readonly?: boolean;
placeholder?: string;
minHeight?: string;
onchange?: (value: string) => void;
}
let {
value = $bindable(''),
language = 'javascript',
readonly = false,
placeholder = '',
minHeight = '200px',
onchange
}: Props = $props();
let editorContainer: HTMLDivElement;
let editorView: EditorView | null = null;
const languageCompartment = new Compartment();
const readonlyCompartment = new Compartment();
function getLanguageExtension(lang: string) {
switch (lang) {
case 'python':
return python();
case 'json':
return json();
case 'javascript':
default:
return javascript();
}
}
onMount(() => {
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
if (newValue !== value) {
value = newValue;
onchange?.(newValue);
}
}
});
const state = EditorState.create({
doc: value,
extensions: [
basicSetup,
languageCompartment.of(getLanguageExtension(language)),
readonlyCompartment.of(EditorState.readOnly.of(readonly)),
oneDark,
updateListener,
EditorView.theme({
'&': { minHeight },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { minHeight },
'&.cm-focused': { outline: 'none' }
}),
placeholder ? EditorView.contentAttributes.of({ 'aria-placeholder': placeholder }) : []
]
});
editorView = new EditorView({
state,
parent: editorContainer
});
});
onDestroy(() => {
editorView?.destroy();
});
// Update editor when value changes externally
$effect(() => {
if (editorView && editorView.state.doc.toString() !== value) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: value
}
});
}
});
// Update language when it changes
$effect(() => {
if (editorView) {
editorView.dispatch({
effects: languageCompartment.reconfigure(getLanguageExtension(language))
});
}
});
// Update readonly when it changes
$effect(() => {
if (editorView) {
editorView.dispatch({
effects: readonlyCompartment.reconfigure(EditorState.readOnly.of(readonly))
});
}
});
</script>
<div class="code-editor rounded-md overflow-hidden border border-surface-500/30" bind:this={editorContainer}></div>
<style>
.code-editor :global(.cm-editor) {
font-size: 14px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
}
.code-editor :global(.cm-gutters) {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
/**
* ToolDocs - Inline documentation panel for tool creation
*/
interface Props {
language: 'javascript' | 'python';
isOpen?: boolean;
onclose?: () => void;
}
const { language, isOpen = false, onclose }: Props = $props();
</script>
{#if isOpen}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary/50 p-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-theme-primary">
{language === 'javascript' ? 'JavaScript' : 'Python'} Tool Guide
</h4>
{#if onclose}
<button
type="button"
onclick={onclose}
class="text-theme-muted hover:text-theme-primary"
aria-label="Close documentation"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<div class="space-y-4 text-sm text-theme-secondary">
{#if language === 'javascript'}
<!-- JavaScript Documentation -->
<div>
<h5 class="font-medium text-theme-primary mb-1">Arguments</h5>
<p>Access parameters via the <code class="bg-theme-primary/30 px-1 rounded text-xs">args</code> object:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>const name = args.name;
const count = args.count || 10;</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Return Value</h5>
<p>Return any JSON-serializable value:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>return {'{'}
success: true,
data: result
{'}'};</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Async/Await</h5>
<p>Full async support - use await for API calls:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>const res = await fetch(args.url);
const data = await res.json();
return data;</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Error Handling</h5>
<p>Throw errors to signal failures:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>if (!args.required_param) {'{'}
throw new Error('Missing required param');
{'}'}</code></pre>
</div>
{:else}
<!-- Python Documentation -->
<div>
<h5 class="font-medium text-theme-primary mb-1">Arguments</h5>
<p>Access parameters via the <code class="bg-theme-primary/30 px-1 rounded text-xs">args</code> dict:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>name = args.get('name')
count = args.get('count', 10)</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Return Value</h5>
<p>Print JSON to stdout (import json first):</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>import json
result = {'{'}'success': True, 'data': data{'}'}
print(json.dumps(result))</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Available Modules</h5>
<p>Python standard library is available:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>import json, math, re
import hashlib, base64
import urllib.request
from collections import Counter</code></pre>
</div>
<div>
<h5 class="font-medium text-theme-primary mb-1">Error Handling</h5>
<p>Print error JSON or raise exceptions:</p>
<pre class="mt-1 p-2 rounded bg-theme-primary/20 text-xs overflow-x-auto"><code>try:
# risky operation
except Exception as e:
print(json.dumps({'{'}'error': str(e){'}'})</code></pre>
</div>
{/if}
<div class="pt-2 border-t border-theme-subtle">
<h5 class="font-medium text-theme-primary mb-1">Tips</h5>
<ul class="list-disc list-inside space-y-1 text-xs text-theme-muted">
<li>Tools run with a 30-second timeout</li>
<li>Large outputs are truncated at 100KB</li>
<li>Network requests are allowed</li>
<li>Use descriptive error messages for debugging</li>
</ul>
</div>
</div>
</div>
{/if}

View File

@@ -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<ToolImplementation>('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<ToolImplementation>('javascript');
// Parameters state (simplified - array of parameter definitions)
let parameters = $state<Array<{ name: string; type: string; description: string; required: boolean }>>([]);
// Validation
let errors = $state<Record<string, string>>({});
// 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 @@
<!-- Implementation Type -->
<div>
<label class="block text-sm font-medium text-theme-secondary">Implementation</label>
<div class="mt-2 flex gap-4">
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
@@ -300,6 +364,15 @@
/>
JavaScript
</label>
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
bind:group={implementation}
value="python"
class="text-blue-500"
/>
Python
</label>
<label class="flex items-center gap-2 text-theme-secondary">
<input
type="radio"
@@ -312,22 +385,102 @@
</div>
</div>
<!-- JavaScript Code -->
{#if implementation === 'javascript'}
<!-- Code Editor (JavaScript or Python) -->
{#if implementation === 'javascript' || implementation === 'python'}
<div>
<label for="tool-code" class="block text-sm font-medium text-theme-secondary">JavaScript Code</label>
<p class="mt-1 text-xs text-theme-muted">
Arguments are passed as an <code class="bg-theme-tertiary px-1 rounded">args</code> object. Return the result.
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-theme-secondary">
{implementation === 'javascript' ? 'JavaScript' : 'Python'} Code
</label>
<div class="flex items-center gap-2">
<!-- Templates dropdown -->
<div class="relative">
<button
type="button"
onclick={() => showTemplates = !showTemplates}
class="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
Templates
</button>
{#if showTemplates && currentTemplates.length > 0}
<div class="absolute right-0 top-full mt-1 w-64 rounded-lg border border-theme-subtle bg-theme-secondary shadow-lg z-10">
<div class="p-2 space-y-1 max-h-48 overflow-y-auto">
{#each currentTemplates as template (template.id)}
<button
type="button"
onclick={() => applyTemplate(template)}
class="w-full text-left px-3 py-2 rounded hover:bg-theme-tertiary"
>
<div class="text-sm text-theme-primary">{template.name}</div>
<div class="text-xs text-theme-muted truncate">{template.description}</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Docs toggle -->
<button
type="button"
onclick={() => showDocs = !showDocs}
class="flex items-center gap-1 text-xs {showDocs ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
Docs
</button>
</div>
</div>
<p class="text-xs text-theme-muted mb-2">
{#if implementation === 'javascript'}
Arguments are passed as an <code class="bg-theme-tertiary px-1 rounded">args</code> object. Return the result.
{:else}
Arguments are available as <code class="bg-theme-tertiary px-1 rounded">args</code> dict. Print JSON result to stdout.
{/if}
</p>
<textarea
id="tool-code"
bind:value={code}
rows="8"
class="mt-2 w-full rounded-lg border border-theme-subtle bg-theme-primary px-3 py-2 font-mono text-sm text-theme-primary placeholder-theme-muted focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
></textarea>
<!-- Documentation panel -->
<ToolDocs
language={implementation}
isOpen={showDocs}
onclose={() => showDocs = false}
/>
<div class="mt-2">
<CodeEditor
bind:value={code}
language={implementation === 'python' ? 'python' : 'javascript'}
minHeight="200px"
/>
</div>
{#if errors.code}
<p class="mt-1 text-sm text-red-400">{errors.code}</p>
{/if}
<!-- Test button -->
<button
type="button"
onclick={() => showTest = !showTest}
class="mt-3 flex items-center gap-2 text-sm {showTest ? 'text-emerald-400' : 'text-theme-muted hover:text-theme-secondary'}"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
{showTest ? 'Hide Test Panel' : 'Test Tool'}
</button>
<!-- Tool tester -->
<ToolTester
{implementation}
{code}
parameters={buildParameterSchema()}
isOpen={showTest}
onclose={() => showTest = false}
/>
</div>
{/if}

View File

@@ -0,0 +1,220 @@
<script lang="ts">
/**
* ToolTester - Test panel for running tools with sample inputs
*/
import type { JSONSchema, ToolImplementation } from '$lib/tools';
import CodeEditor from './CodeEditor.svelte';
interface Props {
implementation: ToolImplementation;
code: string;
parameters: JSONSchema;
isOpen?: boolean;
onclose?: () => void;
}
const { implementation, code, parameters, isOpen = false, onclose }: Props = $props();
let testInput = $state('{}');
let testResult = $state<{ success: boolean; result?: unknown; error?: string } | null>(null);
let isRunning = $state(false);
// Generate example input from parameters
$effect(() => {
if (isOpen && testInput === '{}' && parameters.properties) {
const example: Record<string, unknown> = {};
for (const [name, prop] of Object.entries(parameters.properties)) {
switch (prop.type) {
case 'string':
example[name] = prop.description ? `example_${name}` : '';
break;
case 'number':
example[name] = 0;
break;
case 'boolean':
example[name] = false;
break;
case 'array':
example[name] = [];
break;
case 'object':
example[name] = {};
break;
}
}
if (Object.keys(example).length > 0) {
testInput = JSON.stringify(example, null, 2);
}
}
});
async function runTest(): Promise<void> {
if (isRunning) return;
isRunning = true;
testResult = null;
try {
// Parse the input
let args: Record<string, unknown>;
try {
args = JSON.parse(testInput);
} catch {
testResult = { success: false, error: 'Invalid JSON input' };
isRunning = false;
return;
}
if (implementation === 'javascript') {
// Execute JavaScript directly in browser
try {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const fn = new AsyncFunction(
'args',
`
"use strict";
${code}
`
);
const result = await fn(args);
testResult = { success: true, result };
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else if (implementation === 'python') {
// Python requires backend execution
try {
const response = await fetch('/api/v1/tools/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
language: 'python',
code,
args,
timeout: 30
})
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json();
if (data.success) {
testResult = { success: true, result: data.result };
} else {
testResult = { success: false, error: data.error || 'Unknown error' };
}
} catch (error) {
testResult = {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
} else {
testResult = { success: false, error: 'HTTP tools cannot be tested in the editor' };
}
} finally {
isRunning = false;
}
}
function formatResult(result: unknown): string {
if (result === undefined) return 'undefined';
if (result === null) return 'null';
try {
return JSON.stringify(result, null, 2);
} catch {
return String(result);
}
}
</script>
{#if isOpen}
<div class="rounded-lg border border-theme-subtle bg-theme-tertiary/50 p-4 mt-4">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-theme-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Test Tool
</h4>
{#if onclose}
<button
type="button"
onclick={onclose}
class="text-theme-muted hover:text-theme-primary"
aria-label="Close test panel"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
<div class="space-y-4">
<!-- Input -->
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Input Arguments (JSON)</label>
<CodeEditor bind:value={testInput} language="json" minHeight="80px" />
</div>
<!-- Run button -->
<button
type="button"
onclick={runTest}
disabled={isRunning || !code.trim()}
class="flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-lg bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{#if isRunning}
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Running...
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Run Test
{/if}
</button>
<!-- Result -->
{#if testResult}
<div>
<label class="block text-xs font-medium text-theme-secondary mb-1">Result</label>
<div
class="rounded-lg p-3 text-sm font-mono overflow-x-auto {testResult.success
? 'bg-emerald-900/30 border border-emerald-500/30'
: 'bg-red-900/30 border border-red-500/30'}"
>
{#if testResult.success}
<div class="flex items-center gap-2 text-emerald-400 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
Success
</div>
<pre class="text-theme-primary whitespace-pre-wrap">{formatResult(testResult.result)}</pre>
{:else}
<div class="flex items-center gap-2 text-red-400 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
Error
</div>
<pre class="text-red-300 whitespace-pre-wrap">{testResult.error}</pre>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -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';

View File

@@ -41,6 +41,45 @@ async function executeJavaScriptTool(tool: CustomTool, args: Record<string, unkn
}
}
/**
* Execute a custom Python tool via backend API
*
* SECURITY NOTE: This sends user-provided Python code to the backend for execution.
* This is by design - users create custom tools with their own code.
* The backend executes code in a subprocess with timeout protection.
*/
async function executePythonTool(tool: CustomTool, args: Record<string, unknown>): Promise<unknown> {
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:

View File

@@ -21,3 +21,10 @@ export {
defaultToolConfig,
type ToolConfig
} from './config.js';
export {
toolTemplates,
getTemplatesByLanguage,
getTemplatesByCategory,
getTemplateById,
type ToolTemplate
} from './templates.js';

View File

@@ -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);
}

View File

@@ -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;