5 Commits

Author SHA1 Message Date
a552f4a223 chore: bump version to 0.4.1
Some checks failed
Create Release / release (push) Has been cancelled
2026-01-02 20:36:03 +01:00
4a9e45b40b fix: persist toolCalls to database for reload persistence
Tool usage was not showing after page reload because the toolCalls
field was not being included when saving assistant messages to the
database. Now toolCalls are properly persisted and restored.
2026-01-02 20:34:53 +01:00
862f47c46e feat(tools): enhanced custom tool creation with CodeMirror, Python support, and testing
Some checks failed
Create Release / release (push) Has been cancelled
- 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
2026-01-02 20:15:40 +01:00
5572cd3a0d ci: auto-create GitHub release on tag push 2026-01-02 19:46:29 +01:00
6426850714 chore: add CLAUDE.md to gitignore 2026-01-02 19:42:47 +01:00
18 changed files with 1495 additions and 27 deletions

27
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Create Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
body: |
See the [full release notes on Gitea](https://somegit.dev/vikingowl/vessel/releases) for detailed information.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ backend/server
# Docker
*.pid
docker-compose.override.yml
# Claude Code project instructions (local only)
CLAUDE.md

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.1"
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.1",
"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

@@ -604,12 +604,16 @@
// The results are stored in toolCalls and displayed by ToolCallDisplay
}
// Persist the assistant message (without flooding text content)
// Persist the assistant message (including toolCalls for reload persistence)
if (conversationId && assistantNode) {
const parentOfAssistant = assistantNode.parentId;
await addStoredMessage(
conversationId,
{ role: 'assistant', content: assistantNode.message.content },
{
role: 'assistant',
content: assistantNode.message.content,
toolCalls: assistantNode.message.toolCalls
},
parentOfAssistant,
assistantMessageId
);

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;