mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-10 02:29:43 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c719cbd544 | |||
| 6fa73262fc | |||
| 558fa396a8 | |||
| 33ffe06510 | |||
| a5a602c5b0 | |||
| 500862b768 | |||
| b06522d1ec | |||
| 0c312c9caa |
@@ -83,79 +83,7 @@ jobs:
|
||||
#tags: ${{ steps.prep.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
mcpbox-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
run: |
|
||||
DOCKER_IMAGE=quay.io/mudler/localagi-mcpbox
|
||||
# Use branch name as default
|
||||
VERSION=${GITHUB_REF#refs/heads/}
|
||||
BINARY_VERSION=$(git describe --always --tags --dirty)
|
||||
SHORTREF=${GITHUB_SHA::8}
|
||||
# If this is git tag, use the tag name as a docker tag
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:${SHORTREF}"
|
||||
# If the VERSION looks like a version number, assume that
|
||||
# this is the most recent version of the image and also
|
||||
# tag it 'latest'.
|
||||
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
TAGS="$TAGS,${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
# Set output parameters.
|
||||
echo ::set-output name=binary_version::${BINARY_VERSION}
|
||||
echo ::set-output name=tags::${TAGS}
|
||||
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@master
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@master
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
with:
|
||||
images: quay.io/mudler/localagi-mcpbox
|
||||
tags: |
|
||||
type=ref,event=branch,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||
type=semver,pattern={{raw}}
|
||||
type=sha,suffix=-{{date 'YYYYMMDDHHmmss'}}
|
||||
type=ref,event=branch
|
||||
flavor: |
|
||||
latest=auto
|
||||
prefix=
|
||||
suffix=
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.prep.outputs.binary_version }}
|
||||
context: ./
|
||||
file: ./Dockerfile.mcpbox
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
#tags: ${{ steps.prep.outputs.tags }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
sshbox-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Build stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o mcpbox ./cmd/mcpbox
|
||||
|
||||
# Final stage
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y ca-certificates tzdata docker.io bash wget curl
|
||||
|
||||
# Create non-root user
|
||||
#RUN adduser -D -g '' appuser
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/mcpbox .
|
||||
|
||||
# Use non-root user
|
||||
#USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/app/mcpbox"]
|
||||
|
||||
# Default command
|
||||
CMD ["-addr", ":8080"]
|
||||
+12
-3
@@ -44,12 +44,21 @@ COPY --from=ui-builder /app/dist /work/webui/react-ui/dist
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o localagi ./
|
||||
|
||||
FROM scratch
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
docker.io \
|
||||
bash \
|
||||
wget \
|
||||
curl
|
||||
|
||||
# Copy the webui binary from the builder stage to the final image
|
||||
COPY --from=builder /work/localagi /localagi
|
||||
COPY --from=builder /etc/ssl/ /etc/ssl/
|
||||
COPY --from=builder /tmp /tmp
|
||||
|
||||
# Define the command that will be run when the container is started
|
||||
ENTRYPOINT ["/localagi"]
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
GOCMD?=go
|
||||
IMAGE_NAME?=webui
|
||||
MCPBOX_IMAGE_NAME?=mcpbox
|
||||
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
|
||||
|
||||
prepare-tests: build-mcpbox
|
||||
prepare-tests:
|
||||
docker compose up -d --build
|
||||
docker run -d -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 --rm -ti $(MCPBOX_IMAGE_NAME)
|
||||
|
||||
cleanup-tests:
|
||||
docker compose down
|
||||
|
||||
tests: prepare-tests
|
||||
LOCALAGI_MCPBOX_URL="http://localhost:9090" LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
|
||||
LOCALAGI_MODEL="gemma-3-4b-it-qat" LOCALAI_API_URL="http://localhost:8081" LOCALAGI_API_URL="http://localhost:8080" $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --fail-fast -v -r ./...
|
||||
|
||||
run-nokb:
|
||||
$(MAKE) run KBDISABLEINDEX=true
|
||||
@@ -25,16 +23,10 @@ build: webui/react-ui/dist
|
||||
|
||||
.PHONY: run
|
||||
run: webui/react-ui/dist
|
||||
LOCALAGI_MCPBOX_URL="http://localhost:9090" $(GOCMD) run ./
|
||||
$(GOCMD) run ./
|
||||
|
||||
build-image:
|
||||
docker build -t $(IMAGE_NAME) -f Dockerfile.webui .
|
||||
|
||||
image-push:
|
||||
docker push $(IMAGE_NAME)
|
||||
|
||||
build-mcpbox:
|
||||
docker build -t $(MCPBOX_IMAGE_NAME) -f Dockerfile.mcpbox .
|
||||
|
||||
run-mcpbox:
|
||||
docker run -v /var/run/docker.sock:/var/run/docker.sock --privileged -p 9090:8080 -ti mcpbox
|
||||
docker push $(IMAGE_NAME)
|
||||
@@ -343,7 +343,6 @@ pool, err := state.NewAgentPool(
|
||||
"http://localhost:8080", // API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"", // MCP box URL (optional)
|
||||
"http://localhost:8081", // LocalRAG API URL
|
||||
func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
// Define available actions for agents
|
||||
@@ -703,15 +702,39 @@ The development workflow is similar to the source build, but with additional ste
|
||||
git clone https://github.com/mudler/LocalAGI.git
|
||||
cd LocalAGI
|
||||
|
||||
# Install dependencies and start frontend development server
|
||||
cd webui/react-ui && bun i && bun run dev
|
||||
cd webui/react-ui
|
||||
|
||||
# Install dependencies
|
||||
bun i
|
||||
|
||||
# Compile frontend (the build directory needs to exist for the backend to start)
|
||||
bun run build
|
||||
|
||||
# Start frontend development server
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Then in separate terminal:
|
||||
|
||||
```bash
|
||||
cd LocalAGI
|
||||
|
||||
# Create a "pool" directory for agent state
|
||||
mkdir pool
|
||||
|
||||
# Set required environment variables
|
||||
export LOCALAGI_MODEL=gemma-3-4b-it-qat
|
||||
export LOCALAGI_MULTIMODAL_MODEL=moondream2-20250414
|
||||
export LOCALAGI_IMAGE_MODEL=sd-1.5-ggml
|
||||
export LOCALAGI_LLM_API_URL=http://localai:8080
|
||||
export LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
export LOCALAGI_STATE_DIR=./pool
|
||||
export LOCALAGI_TIMEOUT=5m
|
||||
export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
export LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
|
||||
# Start development server
|
||||
cd ../.. && go run main.go
|
||||
go run main.go
|
||||
```
|
||||
|
||||
> Note: see webui/react-ui/.vite.config.js for env vars that can be used to configure the backend URL
|
||||
@@ -1010,7 +1033,6 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port |
|
||||
| `LOCALAGI_MCPBOX_URL` | LocalAGI MCPBox URL, e.g. http://mcpbox:8080 |
|
||||
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
|
||||
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
|
||||
| `LOCALAGI_CUSTOM_ACTIONS_DIR` | Directory containing custom Go action files to be automatically loaded |
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse command line flags
|
||||
addr := flag.String("addr", ":8080", "HTTP server address")
|
||||
flag.Parse()
|
||||
|
||||
// Create and start the server
|
||||
server := stdio.NewServer()
|
||||
|
||||
// Handle graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
log.Printf("Starting server on %s", *addr)
|
||||
if err := server.Start(*addr); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
<-sigChan
|
||||
log.Println("Shutting down server...")
|
||||
|
||||
// TODO: Implement graceful shutdown if needed
|
||||
os.Exit(0)
|
||||
}
|
||||
+41
-1
@@ -1,15 +1,18 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
@@ -41,7 +44,8 @@ type Agent struct {
|
||||
|
||||
newConversations chan openai.ChatCompletionMessage
|
||||
|
||||
mcpActions types.Actions
|
||||
mcpActions types.Actions
|
||||
mcpSessions []*mcp.ClientSession
|
||||
|
||||
subscriberMutex sync.Mutex
|
||||
newMessagesSubscribers []func(openai.ChatCompletionMessage)
|
||||
@@ -220,6 +224,42 @@ func (a *Agent) Enqueue(j *types.Job) {
|
||||
a.jobQueue <- j
|
||||
}
|
||||
|
||||
func (a *Agent) Transcribe(ctx context.Context, file string) (string, error) {
|
||||
resp, err := a.client.CreateTranscription(ctx,
|
||||
openai.AudioRequest{
|
||||
Model: a.options.LLMAPI.TranscriptionModel,
|
||||
Language: a.options.LLMAPI.TranscriptionLanguage,
|
||||
FilePath: file,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Text, nil
|
||||
}
|
||||
|
||||
func (a *Agent) TTS(ctx context.Context, text string) ([]byte, error) {
|
||||
if a.options.LLMAPI.TTSModel == "" {
|
||||
return nil, fmt.Errorf("TTS model is not set")
|
||||
}
|
||||
resp, err := a.client.CreateSpeech(ctx,
|
||||
openai.CreateSpeechRequest{
|
||||
Model: openai.SpeechModel(a.options.LLMAPI.TTSModel),
|
||||
Input: text,
|
||||
ResponseFormat: openai.SpeechResponseFormatMp3,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
io.Copy(buf, resp)
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) askLLM(ctx context.Context, conversation []openai.ChatCompletionMessage, maxRetries int) (openai.ChatCompletionMessage, error) {
|
||||
var resp openai.ChatCompletionResponse
|
||||
var err error
|
||||
|
||||
+116
-94
@@ -3,13 +3,17 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/http"
|
||||
stdioTransport "github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"net/http"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/stdio"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
@@ -28,7 +32,7 @@ type MCPSTDIOServer struct {
|
||||
}
|
||||
|
||||
type mcpAction struct {
|
||||
mcpClient *mcp.Client
|
||||
mcpClient *mcp.ClientSession
|
||||
inputSchema ToolInputSchema
|
||||
toolName string
|
||||
toolDescription string
|
||||
@@ -39,7 +43,11 @@ func (a *mcpAction) Plannable() bool {
|
||||
}
|
||||
|
||||
func (m *mcpAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
resp, err := m.mcpClient.CallTool(ctx, m.toolName, params)
|
||||
p := &mcp.CallToolParams{
|
||||
Name: m.toolName,
|
||||
Arguments: params,
|
||||
}
|
||||
resp, err := m.mcpClient.CallTool(ctx, p)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to call tool", "error", err.Error())
|
||||
return types.ActionResult{}, err
|
||||
@@ -47,21 +55,17 @@ func (m *mcpAction) Run(ctx context.Context, sharedState *types.AgentSharedState
|
||||
|
||||
xlog.Debug("MCP response", "response", resp)
|
||||
|
||||
textResult := ""
|
||||
for _, c := range resp.Content {
|
||||
switch c.Type {
|
||||
case mcp.ContentTypeText:
|
||||
textResult += c.TextContent.Text + "\n"
|
||||
case mcp.ContentTypeImage:
|
||||
xlog.Error("Image content not supported yet")
|
||||
case mcp.ContentTypeEmbeddedResource:
|
||||
xlog.Error("Embedded resource content not supported yet")
|
||||
}
|
||||
if resp.IsError {
|
||||
log.Error().Msgf("tool failed")
|
||||
return types.ActionResult{}, errors.New("tool failed")
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: textResult,
|
||||
}, nil
|
||||
result := ""
|
||||
for _, c := range resp.Content {
|
||||
result += c.(*mcp.TextContent).Text
|
||||
}
|
||||
|
||||
return types.ActionResult{Result: result}, nil
|
||||
}
|
||||
|
||||
func (m *mcpAction) Definition() types.ActionDefinition {
|
||||
@@ -87,87 +91,107 @@ type ToolInputSchema struct {
|
||||
Required []string `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Agent) addTools(client *mcp.Client) (types.Actions, error) {
|
||||
func (a *Agent) addTools(client *mcp.ClientSession) (types.Actions, error) {
|
||||
|
||||
var generatedActions types.Actions
|
||||
xlog.Debug("Initializing client")
|
||||
// Initialize the client
|
||||
response, e := client.Initialize(a.context)
|
||||
if e != nil {
|
||||
xlog.Error("Failed to initialize client", "error", e.Error())
|
||||
return nil, e
|
||||
|
||||
tools, err := client.ListTools(a.context, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to list tools", "error", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xlog.Debug("Client initialized: %v", response.Instructions)
|
||||
for _, t := range tools.Tools {
|
||||
desc := ""
|
||||
if t.Description != "" {
|
||||
desc = t.Description
|
||||
}
|
||||
|
||||
var cursor *string
|
||||
for {
|
||||
tools, err := client.ListTools(a.context, cursor)
|
||||
xlog.Debug("Tool", "name", t.Name, "description", desc)
|
||||
|
||||
dat, err := json.Marshal(t.InputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to list tools", "error", err.Error())
|
||||
return nil, err
|
||||
xlog.Error("Failed to marshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
for _, t := range tools.Tools {
|
||||
desc := ""
|
||||
if t.Description != nil {
|
||||
desc = *t.Description
|
||||
}
|
||||
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
|
||||
|
||||
xlog.Debug("Tool", "name", t.Name, "description", desc)
|
||||
|
||||
dat, err := json.Marshal(t.InputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to marshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
xlog.Debug("Input schema", "tool", t.Name, "schema", string(dat))
|
||||
|
||||
// XXX: This is a wild guess, to verify (data types might be incompatible)
|
||||
var inputSchema ToolInputSchema
|
||||
err = json.Unmarshal(dat, &inputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
// Create a new action with Client + tool
|
||||
generatedActions = append(generatedActions, &mcpAction{
|
||||
mcpClient: client,
|
||||
toolName: t.Name,
|
||||
inputSchema: inputSchema,
|
||||
toolDescription: desc,
|
||||
})
|
||||
// XXX: This is a wild guess, to verify (data types might be incompatible)
|
||||
var inputSchema ToolInputSchema
|
||||
err = json.Unmarshal(dat, &inputSchema)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to unmarshal input schema", "error", err.Error())
|
||||
}
|
||||
|
||||
if tools.NextCursor == nil {
|
||||
break // No more pages
|
||||
}
|
||||
cursor = tools.NextCursor
|
||||
// Create a new action with Client + tool
|
||||
generatedActions = append(generatedActions, &mcpAction{
|
||||
mcpClient: client,
|
||||
toolName: t.Name,
|
||||
inputSchema: inputSchema,
|
||||
toolDescription: desc,
|
||||
})
|
||||
}
|
||||
|
||||
return generatedActions, nil
|
||||
|
||||
}
|
||||
|
||||
// bearerTokenRoundTripper is a custom roundtripper that injects a bearer token
|
||||
// into HTTP requests
|
||||
type bearerTokenRoundTripper struct {
|
||||
token string
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (rt *bearerTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if rt.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+rt.token)
|
||||
}
|
||||
return rt.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// newBearerTokenRoundTripper creates a new roundtripper that injects the given token
|
||||
func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.RoundTripper {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return &bearerTokenRoundTripper{
|
||||
token: token,
|
||||
base: base,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) initMCPActions() error {
|
||||
|
||||
a.mcpActions = nil
|
||||
var err error
|
||||
|
||||
generatedActions := types.Actions{}
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "LocalAI", Version: "v1.0.0"}, nil)
|
||||
|
||||
// Connect to a server over stdin/stdout.
|
||||
|
||||
// MCP HTTP Servers
|
||||
for _, mcpServer := range a.options.mcpServers {
|
||||
transport := http.NewHTTPClientTransport("/mcp")
|
||||
transport.WithBaseURL(mcpServer.URL)
|
||||
if mcpServer.Token != "" {
|
||||
transport.WithHeader("Authorization", "Bearer "+mcpServer.Token)
|
||||
// Create HTTP client with custom roundtripper for bearer token injection
|
||||
httpclient := &http.Client{
|
||||
Timeout: 360 * time.Second,
|
||||
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
|
||||
}
|
||||
|
||||
transport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
|
||||
// Create a new client
|
||||
client := mcp.NewClient(transport)
|
||||
session, err := client.Connect(a.context, transport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server", "server", mcpServer, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
|
||||
xlog.Debug("Adding tools for MCP server", "server", mcpServer)
|
||||
actions, err := a.addTools(client)
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for MCP server", "server", mcpServer, "error", err.Error())
|
||||
}
|
||||
@@ -179,35 +203,32 @@ func (a *Agent) initMCPActions() error {
|
||||
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
|
||||
|
||||
if a.options.mcpPrepareScript != "" {
|
||||
xlog.Debug("Preparing MCP box", "script", a.options.mcpPrepareScript)
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.RunProcess(a.context, "/bin/bash", []string{"-c", a.options.mcpPrepareScript}, []string{})
|
||||
xlog.Debug("Preparing MCP", "script", a.options.mcpPrepareScript)
|
||||
|
||||
prepareCmd := exec.Command("/bin/bash", "-c", a.options.mcpPrepareScript)
|
||||
output, err := prepareCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
xlog.Error("Failed with error: '%s' - %s", err.Error(), output)
|
||||
}
|
||||
xlog.Debug("Prepared MCP: \n%s", output)
|
||||
}
|
||||
|
||||
for _, mcpStdioServer := range a.options.mcpStdioServers {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
p, err := client.CreateProcess(a.context,
|
||||
mcpStdioServer.Cmd,
|
||||
mcpStdioServer.Args,
|
||||
mcpStdioServer.Env,
|
||||
a.Character.Name)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to create process", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
read, writer, err := client.GetProcessIO(p.ID)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to get process IO", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
transport := stdioTransport.NewStdioServerTransportWithIO(read, writer)
|
||||
command := exec.Command(mcpStdioServer.Cmd, mcpStdioServer.Args...)
|
||||
command.Env = os.Environ()
|
||||
command.Env = append(command.Env, mcpStdioServer.Env...)
|
||||
|
||||
// Create a new client
|
||||
mcpClient := mcp.NewClient(transport)
|
||||
session, err := client.Connect(a.context, &mcp.CommandTransport{
|
||||
Command: command}, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server", "server", mcpStdioServer, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
|
||||
xlog.Debug("Adding tools for MCP server (stdio)", "server", mcpStdioServer)
|
||||
actions, err := a.addTools(mcpClient)
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for MCP server", "server", mcpStdioServer, "error", err.Error())
|
||||
}
|
||||
@@ -220,6 +241,7 @@ func (a *Agent) initMCPActions() error {
|
||||
}
|
||||
|
||||
func (a *Agent) closeMCPSTDIOServers() {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.StopGroup(a.Character.Name)
|
||||
for _, s := range a.mcpSessions {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-4
@@ -14,6 +14,7 @@ type Observer interface {
|
||||
NewObservable() *types.Observable
|
||||
Update(types.Observable)
|
||||
History() []types.Observable
|
||||
ClearHistory()
|
||||
}
|
||||
|
||||
type SSEObserver struct {
|
||||
@@ -21,8 +22,8 @@ type SSEObserver struct {
|
||||
maxID int32
|
||||
manager sse.Manager
|
||||
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
mutex sync.Mutex
|
||||
history []types.Observable
|
||||
historyLast int
|
||||
}
|
||||
|
||||
@@ -36,8 +37,8 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
|
||||
}
|
||||
|
||||
func (s *SSEObserver) NewObservable() *types.Observable {
|
||||
id := atomic.AddInt32(&s.maxID, 1)
|
||||
|
||||
id := atomic.AddInt32(&s.maxID, 1)
|
||||
|
||||
return &types.Observable{
|
||||
ID: id - 1,
|
||||
Agent: s.agent,
|
||||
@@ -86,3 +87,11 @@ func (s *SSEObserver) History() []types.Observable {
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *SSEObserver) ClearHistory() {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.history = make([]types.Observable, 100)
|
||||
s.historyLast = 0
|
||||
}
|
||||
|
||||
+33
-14
@@ -12,10 +12,13 @@ import (
|
||||
type Option func(*options) error
|
||||
|
||||
type llmOptions struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
}
|
||||
|
||||
type options struct {
|
||||
@@ -58,7 +61,6 @@ type options struct {
|
||||
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpBoxURL string
|
||||
mcpPrepareScript string
|
||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||
|
||||
@@ -80,8 +82,11 @@ func defaultOptions() *options {
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
LLMAPI: llmOptions{
|
||||
APIURL: "http://localhost:8080",
|
||||
Model: "gpt-4",
|
||||
APIURL: "http://localhost:8080",
|
||||
Model: "gpt-4",
|
||||
TranscriptionModel: "whisper-1",
|
||||
TranscriptionLanguage: "",
|
||||
TTSModel: "tts-1",
|
||||
},
|
||||
character: Character{
|
||||
Name: "",
|
||||
@@ -238,13 +243,6 @@ func WithMCPSTDIOServers(servers ...MCPSTDIOServer) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPBoxURL(url string) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpBoxURL = url
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPPrepareScript(script string) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpPrepareScript = script
|
||||
@@ -425,3 +423,24 @@ func EnableEvaluation() Option {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTranscriptionModel(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TranscriptionModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTranscriptionLanguage(language string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TranscriptionLanguage = language
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithTTSModel(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.TTSModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
+32
-9
@@ -43,18 +43,20 @@ type AgentConfig struct {
|
||||
MCPServers []agent.MCPServer `json:"mcp_servers" form:"mcp_servers"`
|
||||
MCPSTDIOServers []agent.MCPSTDIOServer `json:"mcp_stdio_servers" form:"mcp_stdio_servers"`
|
||||
MCPPrepareScript string `json:"mcp_prepare_script" form:"mcp_prepare_script"`
|
||||
MCPBoxURL string `json:"mcp_box_url" form:"mcp_box_url"`
|
||||
Filters []FiltersConfig `json:"filters" form:"filters"`
|
||||
|
||||
Description string `json:"description" form:"description"`
|
||||
|
||||
Model string `json:"model" form:"model"`
|
||||
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
|
||||
APIURL string `json:"api_url" form:"api_url"`
|
||||
APIKey string `json:"api_key" form:"api_key"`
|
||||
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
|
||||
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
|
||||
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
|
||||
Model string `json:"model" form:"model"`
|
||||
MultimodalModel string `json:"multimodal_model" form:"multimodal_model"`
|
||||
TranscriptionModel string `json:"transcription_model" form:"transcription_model"`
|
||||
TranscriptionLanguage string `json:"transcription_language" form:"transcription_language"`
|
||||
TTSModel string `json:"tts_model" form:"tts_model"`
|
||||
APIURL string `json:"api_url" form:"api_url"`
|
||||
APIKey string `json:"api_key" form:"api_key"`
|
||||
LocalRAGURL string `json:"local_rag_url" form:"local_rag_url"`
|
||||
LocalRAGAPIKey string `json:"local_rag_api_key" form:"local_rag_api_key"`
|
||||
LastMessageDuration string `json:"last_message_duration" form:"last_message_duration"`
|
||||
|
||||
Name string `json:"name" form:"name"`
|
||||
HUD bool `json:"hud" form:"hud"`
|
||||
@@ -146,6 +148,27 @@ func NewAgentConfigMeta(
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "transcription_model",
|
||||
Label: "Transcription Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "transcription_language",
|
||||
Label: "Transcription Language",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "tts_model",
|
||||
Label: "TTS Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "api_url",
|
||||
Label: "API URL",
|
||||
@@ -301,7 +324,7 @@ func NewAgentConfigMeta(
|
||||
Label: "MCP Prepare Script",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Script to prepare the MCP box",
|
||||
HelpText: "Script to prepare for running MCP servers",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
|
||||
+75
-59
@@ -26,21 +26,21 @@ import (
|
||||
|
||||
type AgentPool struct {
|
||||
sync.Mutex
|
||||
file string
|
||||
pooldir string
|
||||
pool AgentPoolData
|
||||
agents map[string]*Agent
|
||||
managers map[string]sse.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel string
|
||||
mcpBoxURL string
|
||||
imageModel, localRAGAPI, localRAGKey, apiKey string
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
||||
connectors func(*AgentConfig) []Connector
|
||||
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
|
||||
filters func(*AgentConfig) types.JobFilters
|
||||
timeout string
|
||||
conversationLogs string
|
||||
file string
|
||||
pooldir string
|
||||
pool AgentPoolData
|
||||
agents map[string]*Agent
|
||||
managers map[string]sse.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
|
||||
defaultTranscriptionModel, defaultTranscriptionLanguage string
|
||||
imageModel, localRAGAPI, localRAGKey, apiKey string
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
|
||||
connectors func(*AgentConfig) []Connector
|
||||
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
|
||||
filters func(*AgentConfig) types.JobFilters
|
||||
timeout string
|
||||
conversationLogs string
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -74,7 +74,7 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
||||
}
|
||||
|
||||
func NewAgentPool(
|
||||
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, imageModel, apiURL, apiKey, directory string,
|
||||
LocalRAGAPI string,
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
||||
connectors func(*AgentConfig) []Connector,
|
||||
@@ -96,25 +96,27 @@ func NewAgentPool(
|
||||
if _, err := os.Stat(poolfile); err != nil {
|
||||
// file does not exist, create a new pool
|
||||
return &AgentPool{
|
||||
file: poolfile,
|
||||
pooldir: directory,
|
||||
apiURL: apiURL,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
mcpBoxURL: mcpBoxURL,
|
||||
imageModel: imageModel,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sse.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
file: poolfile,
|
||||
pooldir: directory,
|
||||
apiURL: apiURL,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
imageModel: imageModel,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sse.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -123,25 +125,27 @@ func NewAgentPool(
|
||||
return nil, err
|
||||
}
|
||||
return &AgentPool{
|
||||
file: poolfile,
|
||||
apiURL: apiURL,
|
||||
pooldir: directory,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
mcpBoxURL: mcpBoxURL,
|
||||
imageModel: imageModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sse.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
file: poolfile,
|
||||
apiURL: apiURL,
|
||||
pooldir: directory,
|
||||
defaultModel: defaultModel,
|
||||
defaultMultimodalModel: defaultMultimodalModel,
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
imageModel: imageModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sse.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -334,21 +338,31 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
ctx := context.Background()
|
||||
model := a.defaultModel
|
||||
multimodalModel := a.defaultMultimodalModel
|
||||
transcriptionModel := a.defaultTranscriptionModel
|
||||
transcriptionLanguage := a.defaultTranscriptionLanguage
|
||||
ttsModel := a.defaultTTSModel
|
||||
|
||||
if config.MultimodalModel != "" {
|
||||
multimodalModel = config.MultimodalModel
|
||||
}
|
||||
|
||||
if config.TranscriptionModel != "" {
|
||||
transcriptionModel = config.TranscriptionModel
|
||||
}
|
||||
|
||||
if config.TranscriptionLanguage != "" {
|
||||
transcriptionLanguage = config.TranscriptionLanguage
|
||||
}
|
||||
if config.TTSModel != "" {
|
||||
ttsModel = config.TTSModel
|
||||
}
|
||||
|
||||
if config.Model != "" {
|
||||
model = config.Model
|
||||
} else {
|
||||
config.Model = model
|
||||
}
|
||||
|
||||
if config.MCPBoxURL != "" {
|
||||
a.mcpBoxURL = config.MCPBoxURL
|
||||
}
|
||||
|
||||
if config.PeriodicRuns == "" {
|
||||
config.PeriodicRuns = "10m"
|
||||
}
|
||||
@@ -419,10 +433,12 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
WithLLMAPIURL(a.apiURL),
|
||||
WithContext(ctx),
|
||||
WithMCPServers(config.MCPServers...),
|
||||
WithTranscriptionModel(transcriptionModel),
|
||||
WithTranscriptionLanguage(transcriptionLanguage),
|
||||
WithTTSModel(ttsModel),
|
||||
WithPeriodicRuns(config.PeriodicRuns),
|
||||
WithPermanentGoal(config.PermanentGoal),
|
||||
WithMCPSTDIOServers(config.MCPSTDIOServers...),
|
||||
WithMCPBoxURL(a.mcpBoxURL),
|
||||
WithPrompts(promptBlocks...),
|
||||
WithJobFilters(filters...),
|
||||
WithMCPPrepareScript(config.MCPPrepareScript),
|
||||
|
||||
@@ -11,11 +11,6 @@ services:
|
||||
- /dev/dri
|
||||
- /dev/kfd
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -12,11 +12,6 @@ services:
|
||||
- /dev/dri/card1
|
||||
- /dev/dri/renderD129
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -17,11 +17,6 @@ services:
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
+2
-21
@@ -61,25 +61,6 @@ services:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
|
||||
mcpbox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.mcpbox
|
||||
ports:
|
||||
- "8080"
|
||||
volumes:
|
||||
- ./volumes/mcpbox:/app/data
|
||||
environment:
|
||||
- DOCKER_HOST=tcp://dind:2375
|
||||
depends_on:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/processes"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
dind:
|
||||
image: docker:dind
|
||||
privileged: true
|
||||
@@ -97,7 +78,7 @@ services:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
mcpbox:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: .
|
||||
@@ -115,8 +96,8 @@ services:
|
||||
- LOCALAGI_STATE_DIR=/pool
|
||||
- LOCALAGI_TIMEOUT=5m
|
||||
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
- LOCALAGI_MCPBOX_URL=http://mcpbox:8080
|
||||
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
- DOCKER_HOST=tcp://dind:2375
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/google/go-github/v69 v69.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/metoro-io/mcp-golang v0.16.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.0.0
|
||||
github.com/onsi/ginkgo/v2 v2.25.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/philippgille/chromem-go v0.7.0
|
||||
@@ -36,6 +36,8 @@ require (
|
||||
require (
|
||||
github.com/JohannesKaufmann/dom v0.2.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
)
|
||||
|
||||
@@ -47,26 +49,14 @@ require (
|
||||
github.com/antchfx/htmlquery v1.3.4 // indirect
|
||||
github.com/antchfx/xmlquery v1.4.4 // indirect
|
||||
github.com/antchfx/xpath v1.3.4 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.10.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gocolly/colly v1.2.0 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.1.0 // indirect
|
||||
@@ -76,24 +66,15 @@ require (
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/zerolog v1.31.0 // indirect
|
||||
github.com/rs/zerolog v1.31.0
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||
github.com/temoto/robotstxt v1.1.2 // indirect
|
||||
@@ -101,14 +82,10 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
go.mau.fi/util v0.3.0 // indirect
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
@@ -116,6 +93,5 @@ require (
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -17,22 +17,10 @@ github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fus
|
||||
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4=
|
||||
github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/chasefleming/elem-go v0.30.0 h1:BlhV1ekv1RbFiM8XZUQeln1Ikb4D+bu2eDO4agREvok=
|
||||
github.com/chasefleming/elem-go v0.30.0/go.mod h1:hz73qILBIKnTgOujnSMtEj20/epI+f6vg71RUilJAA4=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 h1:flLYmnQFZNo04x2NPehMbf30m7Pli57xwZ0NFqR/hb0=
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2/go.mod h1:NtWqRzAp/1tw+twkW8uuBenEVVYndEAZACWU3F3xdoQ=
|
||||
@@ -53,22 +41,8 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-telegram/bot v1.17.0 h1:Hs0kGxSj97QFqOQP0zxduY/4tSx8QDzvNI9uVRS+zmY=
|
||||
@@ -77,8 +51,6 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
|
||||
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -108,7 +80,8 @@ github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzea
|
||||
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4=
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -116,26 +89,14 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
|
||||
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
|
||||
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -146,24 +107,16 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/metoro-io/mcp-golang v0.16.0 h1:7NrP8Hca4IDLipPitZaTClzmN8uQcQWX8IsziXU813Y=
|
||||
github.com/metoro-io/mcp-golang v0.16.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
|
||||
github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
|
||||
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||
@@ -194,13 +147,7 @@ github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVr
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||
@@ -221,18 +168,14 @@ github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1Ca
|
||||
github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg=
|
||||
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0=
|
||||
github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
@@ -244,8 +187,6 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -342,7 +283,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
|
||||
@@ -353,6 +293,5 @@ maunium.net/go/mautrix v0.17.0 h1:scc1qlUbzPn+wc+3eAPquyD+3gZwwy/hBANBm+iGKK8=
|
||||
maunium.net/go/mautrix v0.17.0/go.mod h1:j+puTEQCEydlVxhJ/dQP5chfa26TdvBO7X6F3Ataav8=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
|
||||
var baseModel = os.Getenv("LOCALAGI_MODEL")
|
||||
var multimodalModel = os.Getenv("LOCALAGI_MULTIMODAL_MODEL")
|
||||
var transcriptionModel = os.Getenv("LOCALAGI_TRANSCRIPTION_MODEL")
|
||||
var transcriptionLanguage = os.Getenv("LOCALAGI_TRANSCRIPTION_LANGUAGE")
|
||||
var ttsModel = os.Getenv("LOCALAGI_TTS_MODEL")
|
||||
var apiURL = os.Getenv("LOCALAGI_LLM_API_URL")
|
||||
var apiKey = os.Getenv("LOCALAGI_LLM_API_KEY")
|
||||
var timeout = os.Getenv("LOCALAGI_TIMEOUT")
|
||||
@@ -23,7 +26,6 @@ var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
|
||||
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
|
||||
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
|
||||
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
|
||||
var mcpboxURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||
var customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR")
|
||||
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
|
||||
|
||||
@@ -60,11 +62,13 @@ func main() {
|
||||
pool, err := state.NewAgentPool(
|
||||
baseModel,
|
||||
multimodalModel,
|
||||
transcriptionModel,
|
||||
transcriptionLanguage,
|
||||
ttsModel,
|
||||
imageModel,
|
||||
apiURL,
|
||||
apiKey,
|
||||
stateDir,
|
||||
mcpboxURL,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client implements the transport.Interface for stdio processes
|
||||
type Client struct {
|
||||
baseURL string
|
||||
processes map[string]*Process
|
||||
groups map[string][]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewClient creates a new stdio transport client
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
processes: make(map[string]*Process),
|
||||
groups: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProcess starts a new process in a group
|
||||
func (c *Client) CreateProcess(ctx context.Context, command string, args []string, env []string, groupID string) (*Process, error) {
|
||||
log.Printf("Creating process: command=%s, args=%v, groupID=%s", command, args, groupID)
|
||||
|
||||
req := struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
GroupID string `json:"group_id"`
|
||||
}{
|
||||
Command: command,
|
||||
Args: args,
|
||||
Env: env,
|
||||
GroupID: groupID,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/processes", c.baseURL)
|
||||
log.Printf("Sending POST request to %s", url)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("Successfully created process with ID: %s", result.ID)
|
||||
|
||||
process := &Process{
|
||||
ID: result.ID,
|
||||
GroupID: groupID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.processes[process.ID] = process
|
||||
if groupID != "" {
|
||||
c.groups[groupID] = append(c.groups[groupID], process.ID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// GetProcess returns a process by ID
|
||||
func (c *Client) GetProcess(id string) (*Process, error) {
|
||||
c.mu.RLock()
|
||||
process, exists := c.processes[id]
|
||||
c.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// GetGroupProcesses returns all processes in a group
|
||||
func (c *Client) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||
c.mu.RLock()
|
||||
processIDs, exists := c.groups[groupID]
|
||||
if !exists {
|
||||
c.mu.RUnlock()
|
||||
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
|
||||
processes := make([]*Process, 0, len(processIDs))
|
||||
for _, pid := range processIDs {
|
||||
if process, exists := c.processes[pid]; exists {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
// StopProcess stops a single process
|
||||
func (c *Client) StopProcess(id string) error {
|
||||
c.mu.Lock()
|
||||
process, exists := c.processes[id]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
// Remove from group if it exists
|
||||
if process.GroupID != "" {
|
||||
groupProcesses := c.groups[process.GroupID]
|
||||
for i, pid := range groupProcesses {
|
||||
if pid == id {
|
||||
c.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(c.groups[process.GroupID]) == 0 {
|
||||
delete(c.groups, process.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
delete(c.processes, id)
|
||||
c.mu.Unlock()
|
||||
|
||||
req, err := http.NewRequest(
|
||||
"DELETE",
|
||||
fmt.Sprintf("%s/processes/%s", c.baseURL, id),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop process: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopGroup stops all processes in a group
|
||||
func (c *Client) StopGroup(groupID string) error {
|
||||
c.mu.Lock()
|
||||
processIDs, exists := c.groups[groupID]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
for _, pid := range processIDs {
|
||||
if err := c.StopProcess(pid); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListGroups returns all group IDs
|
||||
func (c *Client) ListGroups() []string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
groups := make([]string, 0, len(c.groups))
|
||||
for groupID := range c.groups {
|
||||
groups = append(groups, groupID)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetProcessIO returns io.Reader and io.Writer for a process
|
||||
func (c *Client) GetProcessIO(id string) (io.Reader, io.Writer, error) {
|
||||
log.Printf("Getting IO for process: %s", id)
|
||||
|
||||
process, err := c.GetProcess(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Parse the base URL to get the host
|
||||
baseURL, err := url.Parse(c.baseURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse base URL: %w", err)
|
||||
}
|
||||
|
||||
// Connect to WebSocket
|
||||
u := url.URL{
|
||||
Scheme: "ws",
|
||||
Host: baseURL.Host,
|
||||
Path: fmt.Sprintf("/ws/%s", process.ID),
|
||||
}
|
||||
|
||||
log.Printf("Connecting to WebSocket at: %s", u.String())
|
||||
|
||||
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to WebSocket: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully connected to WebSocket for process: %s", id)
|
||||
|
||||
// Create reader and writer
|
||||
reader := &websocketReader{conn: conn}
|
||||
writer := &websocketWriter{conn: conn}
|
||||
|
||||
return reader, writer, nil
|
||||
}
|
||||
|
||||
// websocketReader implements io.Reader for WebSocket
|
||||
type websocketReader struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
func (r *websocketReader) Read(p []byte) (n int, err error) {
|
||||
_, message, err := r.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n = copy(p, message)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// websocketWriter implements io.Writer for WebSocket
|
||||
type websocketWriter struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
func (w *websocketWriter) Write(p []byte) (n int, err error) {
|
||||
// Use BinaryMessage type for better compatibility
|
||||
err = w.conn.WriteMessage(websocket.BinaryMessage, p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write WebSocket message: %w", err)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Close closes all connections and stops all processes
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Stop all processes
|
||||
for id := range c.processes {
|
||||
if err := c.StopProcess(id); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunProcess executes a command and returns its output
|
||||
func (c *Client) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||
log.Printf("Running one-time process: command=%s, args=%v", command, args)
|
||||
|
||||
req := struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}{
|
||||
Command: command,
|
||||
Args: args,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/run", c.baseURL)
|
||||
log.Printf("Sending POST request to %s", url)
|
||||
|
||||
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to execute process: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("Received response with status: %d", resp.StatusCode)
|
||||
|
||||
var result struct {
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("failed to decode response: %w. body: %s", err, string(body))
|
||||
}
|
||||
|
||||
log.Printf("Successfully executed process with output length: %d", len(result.Output))
|
||||
return result.Output, nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestSTDIOTransport(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "STDIOTransport test suite")
|
||||
}
|
||||
|
||||
var baseURL string
|
||||
|
||||
func init() {
|
||||
baseURL = os.Getenv("LOCALAGI_MCPBOX_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
|
||||
var _ = AfterSuite(func() {
|
||||
client := NewClient(baseURL)
|
||||
client.StopGroup("test-group")
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
mcp "github.com/metoro-io/mcp-golang"
|
||||
"github.com/metoro-io/mcp-golang/transport/stdio"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Client", func() {
|
||||
var (
|
||||
client *Client
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
client = NewClient(baseURL)
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if client != nil {
|
||||
Expect(client.Close()).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
Context("Process Management", func() {
|
||||
It("should create and stop a process", func() {
|
||||
ctx := context.Background()
|
||||
// Use a command that doesn't exit immediately
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Hello, World!'; sleep 10"}, []string{}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
Expect(process.ID).NotTo(BeEmpty())
|
||||
|
||||
// Get process IO
|
||||
reader, writer, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(reader).NotTo(BeNil())
|
||||
Expect(writer).NotTo(BeNil())
|
||||
|
||||
// Write to process
|
||||
_, err = writer.Write([]byte("test input\n"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read from process with timeout
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("Hello, World!"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should manage process groups", func() {
|
||||
ctx := context.Background()
|
||||
groupID := "test-group"
|
||||
|
||||
// Create multiple processes in the same group
|
||||
process1, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 1'; sleep 1"}, []string{}, groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process1).NotTo(BeNil())
|
||||
|
||||
process2, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Process 2'; sleep 1"}, []string{}, groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process2).NotTo(BeNil())
|
||||
|
||||
// Get group processes
|
||||
processes, err := client.GetGroupProcesses(groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(processes).To(HaveLen(2))
|
||||
|
||||
// List groups
|
||||
groups := client.ListGroups()
|
||||
Expect(groups).To(ContainElement(groupID))
|
||||
|
||||
// Stop the group
|
||||
err = client.StopGroup(groupID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should run a one-time process", func() {
|
||||
ctx := context.Background()
|
||||
output, err := client.RunProcess(ctx, "echo", []string{"One-time process"}, []string{})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(output).To(ContainSubstring("One-time process"))
|
||||
})
|
||||
|
||||
It("should handle process with environment variables", func() {
|
||||
ctx := context.Background()
|
||||
env := []string{"TEST_VAR=test_value"}
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "env | grep TEST_VAR; sleep 1"}, env, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
|
||||
// Get process IO
|
||||
reader, _, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read environment variables with timeout
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("TEST_VAR=test_value"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should handle long-running processes", func() {
|
||||
ctx := context.Background()
|
||||
process, err := client.CreateProcess(ctx, "sh", []string{"-c", "echo 'Starting long process'; sleep 5"}, []string{}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
|
||||
// Get process IO
|
||||
reader, _, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Read initial output
|
||||
buf := make([]byte, 1024)
|
||||
readDone := make(chan struct{})
|
||||
var readErr error
|
||||
var readN int
|
||||
|
||||
go func() {
|
||||
readN, readErr = reader.Read(buf)
|
||||
close(readDone)
|
||||
}()
|
||||
|
||||
// Wait for read with timeout
|
||||
select {
|
||||
case <-readDone:
|
||||
Expect(readErr).NotTo(HaveOccurred())
|
||||
Expect(readN).To(BeNumerically(">", 0))
|
||||
Expect(string(buf[:readN])).To(ContainSubstring("Starting long process"))
|
||||
case <-time.After(5 * time.Second):
|
||||
Fail("Timeout waiting for process output")
|
||||
}
|
||||
|
||||
// Wait a bit to ensure process is running
|
||||
time.Sleep(time.Second)
|
||||
|
||||
// Stop the process
|
||||
err = client.StopProcess(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
})
|
||||
|
||||
It("MCP", func() {
|
||||
ctx := context.Background()
|
||||
process, err := client.CreateProcess(ctx,
|
||||
"docker", []string{"run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"},
|
||||
[]string{"GITHUB_PERSONAL_ACCESS_TOKEN=test"}, "test-group")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(process).NotTo(BeNil())
|
||||
Expect(process.ID).NotTo(BeEmpty())
|
||||
|
||||
defer client.StopProcess(process.ID)
|
||||
|
||||
// MCP client
|
||||
|
||||
read, writer, err := client.GetProcessIO(process.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(read).NotTo(BeNil())
|
||||
Expect(writer).NotTo(BeNil())
|
||||
|
||||
transport := stdio.NewStdioServerTransportWithIO(read, writer)
|
||||
|
||||
// Create a new client
|
||||
mcpClient := mcp.NewClient(transport)
|
||||
// Initialize the client
|
||||
response, e := mcpClient.Initialize(ctx)
|
||||
Expect(e).NotTo(HaveOccurred())
|
||||
Expect(response).NotTo(BeNil())
|
||||
|
||||
Expect(mcpClient.Ping(ctx)).To(Succeed())
|
||||
|
||||
xlog.Debug("Client initialized: %v", response.Instructions)
|
||||
|
||||
alltools := []mcp.ToolRetType{}
|
||||
var cursor *string
|
||||
for {
|
||||
tools, err := mcpClient.ListTools(ctx, cursor)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tools).NotTo(BeNil())
|
||||
Expect(tools.Tools).NotTo(BeEmpty())
|
||||
alltools = append(alltools, tools.Tools...)
|
||||
|
||||
if tools.NextCursor == nil {
|
||||
break // No more pages
|
||||
}
|
||||
cursor = tools.NextCursor
|
||||
}
|
||||
|
||||
for _, tool := range alltools {
|
||||
xlog.Debug("Tool: %v", tool)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,473 +0,0 @@
|
||||
package stdio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
// Process represents a running process with its stdio streams
|
||||
type Process struct {
|
||||
ID string
|
||||
GroupID string
|
||||
Cmd *exec.Cmd
|
||||
Stdin io.WriteCloser
|
||||
Stdout io.ReadCloser
|
||||
Stderr io.ReadCloser
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Server handles process management and stdio streaming
|
||||
type Server struct {
|
||||
processes map[string]*Process
|
||||
groups map[string][]string // maps group ID to process IDs
|
||||
mu sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewServer creates a new stdio server
|
||||
func NewServer() *Server {
|
||||
return &Server{
|
||||
processes: make(map[string]*Process),
|
||||
groups: make(map[string][]string),
|
||||
upgrader: websocket.Upgrader{},
|
||||
}
|
||||
}
|
||||
|
||||
// StartProcess starts a new process and returns its ID
|
||||
func (s *Server) StartProcess(ctx context.Context, command string, args []string, env []string, groupID string) (string, error) {
|
||||
xlog.Debug("Starting process", "command", command, "args", args, "groupID", groupID)
|
||||
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
xlog.Debug("Process environment", "env", cmd.Env)
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", fmt.Errorf("failed to start process: %w", err)
|
||||
}
|
||||
|
||||
process := &Process{
|
||||
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||
GroupID: groupID,
|
||||
Cmd: cmd,
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.processes[process.ID] = process
|
||||
if groupID != "" {
|
||||
s.groups[groupID] = append(s.groups[groupID], process.ID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
xlog.Debug("Successfully started process", "id", process.ID, "pid", cmd.Process.Pid)
|
||||
return process.ID, nil
|
||||
}
|
||||
|
||||
// StopProcess stops a running process
|
||||
func (s *Server) StopProcess(id string) error {
|
||||
s.mu.Lock()
|
||||
process, exists := s.processes[id]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
xlog.Debug("Stopping process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
|
||||
// Remove from group if it exists
|
||||
if process.GroupID != "" {
|
||||
groupProcesses := s.groups[process.GroupID]
|
||||
for i, pid := range groupProcesses {
|
||||
if pid == id {
|
||||
s.groups[process.GroupID] = append(groupProcesses[:i], groupProcesses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(s.groups[process.GroupID]) == 0 {
|
||||
delete(s.groups, process.GroupID)
|
||||
}
|
||||
}
|
||||
|
||||
delete(s.processes, id)
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := process.Cmd.Process.Kill(); err != nil {
|
||||
xlog.Debug("Failed to kill process", "processID", id, "pid", process.Cmd.Process.Pid, "error", err)
|
||||
return fmt.Errorf("failed to kill process: %w", err)
|
||||
}
|
||||
|
||||
xlog.Debug("Successfully killed process", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopGroup stops all processes in a group
|
||||
func (s *Server) StopGroup(groupID string) error {
|
||||
s.mu.Lock()
|
||||
processIDs, exists := s.groups[groupID]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
for _, pid := range processIDs {
|
||||
if err := s.StopProcess(pid); err != nil {
|
||||
return fmt.Errorf("failed to stop process %s in group %s: %w", pid, groupID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGroupProcesses returns all processes in a group
|
||||
func (s *Server) GetGroupProcesses(groupID string) ([]*Process, error) {
|
||||
s.mu.RLock()
|
||||
processIDs, exists := s.groups[groupID]
|
||||
if !exists {
|
||||
s.mu.RUnlock()
|
||||
return nil, fmt.Errorf("group not found: %s", groupID)
|
||||
}
|
||||
|
||||
processes := make([]*Process, 0, len(processIDs))
|
||||
for _, pid := range processIDs {
|
||||
if process, exists := s.processes[pid]; exists {
|
||||
processes = append(processes, process)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
return processes, nil
|
||||
}
|
||||
|
||||
// ListGroups returns all group IDs
|
||||
func (s *Server) ListGroups() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
groups := make([]string, 0, len(s.groups))
|
||||
for groupID := range s.groups {
|
||||
groups = append(groups, groupID)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// GetProcess returns a process by ID
|
||||
func (s *Server) GetProcess(id string) (*Process, error) {
|
||||
s.mu.RLock()
|
||||
process, exists := s.processes[id]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("process not found: %s", id)
|
||||
}
|
||||
|
||||
return process, nil
|
||||
}
|
||||
|
||||
// ListProcesses returns all running processes
|
||||
func (s *Server) ListProcesses() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
processes := make([]*Process, 0, len(s.processes))
|
||||
for _, p := range s.processes {
|
||||
processes = append(processes, p)
|
||||
}
|
||||
|
||||
return processes
|
||||
}
|
||||
|
||||
// RunProcess executes a command and returns its output
|
||||
func (s *Server) RunProcess(ctx context.Context, command string, args []string, env []string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return string(output), fmt.Errorf("process failed: %w", err)
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start(addr string) error {
|
||||
http.HandleFunc("/processes", s.handleProcesses)
|
||||
http.HandleFunc("/processes/", s.handleProcess)
|
||||
http.HandleFunc("/ws/", s.handleWebSocket)
|
||||
http.HandleFunc("/groups", s.handleGroups)
|
||||
http.HandleFunc("/groups/", s.handleGroup)
|
||||
http.HandleFunc("/run", s.handleRun)
|
||||
|
||||
return http.ListenAndServe(addr, nil)
|
||||
}
|
||||
|
||||
func (s *Server) handleProcesses(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Handling /processes request: method=%s", r.Method)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
processes := s.ListProcesses()
|
||||
json.NewEncoder(w).Encode(processes)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
GroupID string `json:"group_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := s.StartProcess(context.Background(), req.Command, req.Args, req.Env, req.GroupID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/processes/"):]
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
process, err := s.GetProcess(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(process)
|
||||
case http.MethodDelete:
|
||||
if err := s.StopProcess(id); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Path[len("/ws/"):]
|
||||
xlog.Debug("Handling WebSocket connection", "processID", id)
|
||||
|
||||
process, err := s.GetProcess(id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if process.Cmd.ProcessState != nil && process.Cmd.ProcessState.Exited() {
|
||||
xlog.Debug("Process already exited", "processID", id)
|
||||
http.Error(w, "Process already exited", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
xlog.Debug("Process is running", "processID", id, "pid", process.Cmd.Process.Pid)
|
||||
|
||||
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
xlog.Debug("WebSocket connection established", "processID", id)
|
||||
|
||||
// Create a done channel to signal process completion
|
||||
done := make(chan struct{})
|
||||
|
||||
// Handle stdin
|
||||
go func() {
|
||||
defer func() {
|
||||
select {
|
||||
case <-done:
|
||||
xlog.Debug("Process stdin handler done", "processID", id)
|
||||
default:
|
||||
xlog.Debug("WebSocket stdin connection closed", "processID", id)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
_, message, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
xlog.Debug("WebSocket stdin unexpected error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Received message", "processID", id, "message", string(message))
|
||||
if _, err := process.Stdin.Write(message); err != nil {
|
||||
if err != io.EOF {
|
||||
xlog.Debug("WebSocket stdin write error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Message sent to process", "processID", id, "message", string(message))
|
||||
}
|
||||
}()
|
||||
|
||||
// Handle stdout and stderr
|
||||
go func() {
|
||||
defer func() {
|
||||
select {
|
||||
case <-done:
|
||||
xlog.Debug("Process output handler done", "processID", id)
|
||||
default:
|
||||
xlog.Debug("WebSocket output connection closed", "processID", id)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a buffer for reading
|
||||
buf := make([]byte, 4096)
|
||||
reader := io.MultiReader(process.Stdout, process.Stderr)
|
||||
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
xlog.Debug("Read error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
xlog.Debug("Sending message", "processID", id, "size", n)
|
||||
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
xlog.Debug("WebSocket output write error", "processID", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
xlog.Debug("Message sent to client", "processID", id, "size", n)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for process to exit
|
||||
xlog.Debug("Waiting for process to exit", "processID", id)
|
||||
err = process.Cmd.Wait()
|
||||
close(done) // Signal that the process is done
|
||||
|
||||
if err != nil {
|
||||
xlog.Debug("Process exited with error",
|
||||
"processID", id,
|
||||
"pid", process.Cmd.Process.Pid,
|
||||
"error", err)
|
||||
} else {
|
||||
xlog.Debug("Process exited successfully",
|
||||
"processID", id,
|
||||
"pid", process.Cmd.Process.Pid)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new handlers for group management
|
||||
func (s *Server) handleGroups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
groups := s.ListGroups()
|
||||
json.NewEncoder(w).Encode(groups)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleGroup(w http.ResponseWriter, r *http.Request) {
|
||||
groupID := r.URL.Path[len("/groups/"):]
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
processes, err := s.GetGroupProcesses(groupID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(processes)
|
||||
case http.MethodDelete:
|
||||
if err := s.StopGroup(groupID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Handling /run request")
|
||||
|
||||
var req struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Executing one-time process: command=%s, args=%v", req.Command, req.Args)
|
||||
|
||||
output, err := s.RunProcess(r.Context(), req.Command, req.Args, req.Env)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("One-time process completed with output length: %d", len(output))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"output": output,
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,12 @@ func NewWebhook(cfg map[string]string) *WebhookAction {
|
||||
contentType: strings.TrimSpace(cfg["contentType"]),
|
||||
payloadTemplate: cfg["payloadTemplate"],
|
||||
}
|
||||
// Optional custom overrides
|
||||
if cfg != nil {
|
||||
wa.customName = cfg["custom_name"]
|
||||
wa.customDescription = cfg["custom_description"]
|
||||
wa.customPayloadDescription = cfg["custom_payload_description"]
|
||||
}
|
||||
if wa.method == "" {
|
||||
wa.method = http.MethodPost
|
||||
}
|
||||
@@ -48,10 +54,13 @@ func NewWebhook(cfg map[string]string) *WebhookAction {
|
||||
//
|
||||
// Note: This action does not follow redirects!
|
||||
type WebhookAction struct {
|
||||
url string
|
||||
method string
|
||||
contentType string
|
||||
payloadTemplate string
|
||||
url string
|
||||
method string
|
||||
contentType string
|
||||
payloadTemplate string
|
||||
customName string
|
||||
customDescription string
|
||||
customPayloadDescription string
|
||||
}
|
||||
|
||||
// Run executes the webhook call.
|
||||
@@ -151,13 +160,25 @@ func (a *WebhookAction) Run(ctx context.Context, sharedState *types.AgentSharedS
|
||||
// Only the runtime parameter "payload" is accepted; all connection details
|
||||
// are configured statically via the action configuration UI.
|
||||
func (a *WebhookAction) Definition() types.ActionDefinition {
|
||||
name := "webhook"
|
||||
description := "Send an HTTP request to a configured URL/method/content-type. Accepts a runtime payload parameter optionally inserted into the configured payload template."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
payloadDesc := "Payload/body to send with the request at runtime. If a payloadTemplate is configured, '{{payload}}' will be replaced by this value."
|
||||
if a.customPayloadDescription != "" {
|
||||
payloadDesc = a.customPayloadDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: "webhook",
|
||||
Description: "Send an HTTP request to a configured URL/method/content-type. Accepts a runtime payload parameter optionally inserted into the configured payload template.",
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"payload": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Payload/body to send with the request at runtime. If a payloadTemplate is configured, '{{payload}}' will be replaced by this value.",
|
||||
Description: payloadDesc,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -176,6 +197,27 @@ func (a *WebhookAction) Plannable() bool { return true }
|
||||
// as-is; for GET, no body is sent regardless.
|
||||
func WebhookConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "custom_name",
|
||||
Label: "Custom Name",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom name for the action (optional, defaults to 'webhook')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
{
|
||||
Name: "custom_payload_description",
|
||||
Label: "Custom Payload Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Override the payload parameter description shown in the UI/schema (optional).",
|
||||
},
|
||||
{
|
||||
Name: "url",
|
||||
Label: "URL",
|
||||
|
||||
+213
-42
@@ -56,14 +56,24 @@ func (t *Telegram) isBotMentioned(message string, botUsername string) bool {
|
||||
}
|
||||
|
||||
func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
|
||||
if len(update.Message.Photo) == 0 {
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
}, nil
|
||||
// Handle audio messages
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
return t.handleAudioMessage(update)
|
||||
}
|
||||
|
||||
// Handle photo messages
|
||||
if len(update.Message.Photo) > 0 {
|
||||
return t.handlePhotoMessage(update)
|
||||
}
|
||||
|
||||
// Handle text messages
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: update.Message.Text,
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) handlePhotoMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
xlog.Debug("Image", "found image")
|
||||
// Get the largest photo
|
||||
photo := update.Message.Photo[len(update.Message.Photo)-1]
|
||||
@@ -77,44 +87,122 @@ func (t *Telegram) chatFromMessage(update *models.Update) (openai.ChatCompletion
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting file", "error", err)
|
||||
} else {
|
||||
// Construct the full URL for downloading the file
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
|
||||
|
||||
// Download the file content
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading file", "error", err)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image", "error", err)
|
||||
} else {
|
||||
// Encode to base64
|
||||
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
xlog.Debug("Image", "sending encoded image")
|
||||
// Add to conversation as multi-content message
|
||||
return openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: update.Message.Caption,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
return openai.ChatCompletionMessage{}, errors.New("no image found")
|
||||
// Construct the full URL for downloading the file
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
|
||||
|
||||
// Download the file content
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
imageBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Encode to base64
|
||||
imgBase64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||
xlog.Debug("Image", "sending encoded image")
|
||||
// Add to conversation as multi-content message
|
||||
return openai.ChatCompletionMessage{
|
||||
Role: "user",
|
||||
MultiContent: []openai.ChatMessagePart{
|
||||
{
|
||||
Text: update.Message.Caption,
|
||||
Type: openai.ChatMessagePartTypeText,
|
||||
},
|
||||
{
|
||||
Type: openai.ChatMessagePartTypeImageURL,
|
||||
ImageURL: &openai.ChatMessageImageURL{
|
||||
URL: fmt.Sprintf("data:image/jpeg;base64,%s", imgBase64),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Telegram) handleAudioMessage(update *models.Update) (openai.ChatCompletionMessage, error) {
|
||||
var fileID string
|
||||
var audioType string
|
||||
|
||||
if update.Message.Voice != nil {
|
||||
fileID = update.Message.Voice.FileID
|
||||
audioType = "voice"
|
||||
} else if update.Message.Audio != nil {
|
||||
fileID = update.Message.Audio.FileID
|
||||
audioType = "audio"
|
||||
}
|
||||
|
||||
xlog.Debug("Audio message received", "type", audioType, "fileID", fileID)
|
||||
|
||||
ctx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Download the audio file
|
||||
file, err := t.bot.GetFile(ctx, &bot.GetFileParams{
|
||||
FileID: fileID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error getting audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Construct the full URL for downloading the file
|
||||
fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", t.Token, file.FilePath)
|
||||
|
||||
// Download the file content
|
||||
resp, err := http.Get(fileURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
audioBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading audio file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
|
||||
// Create a temporary file for transcription
|
||||
tempFile, err := os.CreateTemp("", "telegram_audio_*.ogg")
|
||||
if err != nil {
|
||||
xlog.Error("Error creating temp file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
defer os.Remove(tempFile.Name())
|
||||
|
||||
// Write audio data to temp file
|
||||
if _, err := tempFile.Write(audioBytes); err != nil {
|
||||
tempFile.Close()
|
||||
xlog.Error("Error writing audio to temp file", "error", err)
|
||||
return openai.ChatCompletionMessage{}, err
|
||||
}
|
||||
tempFile.Close()
|
||||
|
||||
// Transcribe the audio using the agent's Transcribe method
|
||||
transcription, err := t.agent.Transcribe(ctx, tempFile.Name())
|
||||
if err != nil {
|
||||
xlog.Error("Error transcribing audio", "error", err)
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: fmt.Sprintf("I received an audio message but couldn't transcribe it: %v", err),
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
xlog.Debug("Audio transcribed successfully", "transcription", transcription)
|
||||
return openai.ChatCompletionMessage{
|
||||
Content: transcription,
|
||||
Role: "user",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleGroupMessage handles messages in group chats
|
||||
@@ -176,6 +264,11 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
"chatID": update.Message.Chat.ID,
|
||||
}
|
||||
|
||||
// Track if the original message was audio for TTS response
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
metadata["originalMessageType"] = "audio"
|
||||
}
|
||||
|
||||
chatMessage, err := t.chatFromMessage(update)
|
||||
if err != nil {
|
||||
xlog.Error("Error extracting chat message", "error", err)
|
||||
@@ -251,6 +344,34 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
xlog.Error("Error handling multimedia content", "error", err)
|
||||
}
|
||||
|
||||
// Check if original message was audio and generate TTS response
|
||||
if metadata["originalMessageType"] == "audio" && res.Response != "" {
|
||||
|
||||
xlog.Debug("Original message was audio, generating TTS response")
|
||||
audioData, err := t.agent.TTS(ctx, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error generating TTS", "error", err)
|
||||
} else {
|
||||
// Send audio response
|
||||
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error sending audio response", "error", err)
|
||||
} else {
|
||||
xlog.Debug("Audio response sent successfully")
|
||||
// Remove the thinking placeholder message before returning
|
||||
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
MessageID: msg.ID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error deleting thinking placeholder", "error", err)
|
||||
}
|
||||
// Don't send text response if audio was sent successfully
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the message with the final response
|
||||
formattedResponse := formatResponseWithURLs(res.Response, urls)
|
||||
|
||||
@@ -396,6 +517,24 @@ func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url stri
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendAudioToTelegram sends audio data to Telegram
|
||||
func sendAudioToTelegram(ctx context.Context, b *bot.Bot, chatID int64, audioData []byte, caption string) error {
|
||||
// Send audio with caption
|
||||
_, err := b.SendVoice(ctx, &bot.SendVoiceParams{
|
||||
ChatID: chatID,
|
||||
Voice: &models.InputFileUpload{
|
||||
Filename: "response.mp3",
|
||||
Data: bytes.NewReader(audioData),
|
||||
},
|
||||
Caption: caption,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending audio: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleMultimediaContent processes and sends multimedia content from the agent's response
|
||||
func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, res *types.JobResult) ([]string, error) {
|
||||
var urls []string
|
||||
@@ -540,6 +679,11 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
"chatID": update.Message.Chat.ID,
|
||||
}
|
||||
|
||||
// Track if the original message was audio for TTS response
|
||||
if update.Message.Voice != nil || update.Message.Audio != nil {
|
||||
metadata["originalMessageType"] = "audio"
|
||||
}
|
||||
|
||||
// Create a new job with the conversation history and metadata
|
||||
job := types.NewJob(
|
||||
types.WithConversationHistory(currentConv),
|
||||
@@ -603,6 +747,33 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
xlog.Error("Error handling multimedia content", "error", err)
|
||||
}
|
||||
|
||||
// Check if original message was audio and generate TTS response
|
||||
if metadata["originalMessageType"] == "audio" && res.Response != "" {
|
||||
xlog.Debug("Original message was audio, generating TTS response")
|
||||
audioData, err := t.agent.TTS(ctx, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error generating TTS", "error", err)
|
||||
} else {
|
||||
// Send audio response
|
||||
err = sendAudioToTelegram(ctx, t.bot, update.Message.Chat.ID, audioData, res.Response)
|
||||
if err != nil {
|
||||
xlog.Error("Error sending audio response", "error", err)
|
||||
} else {
|
||||
xlog.Debug("Audio response sent successfully")
|
||||
// Remove the thinking placeholder message before returning
|
||||
_, err := t.bot.DeleteMessage(ctx, &bot.DeleteMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
MessageID: msg.ID,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error deleting thinking placeholder", "error", err)
|
||||
}
|
||||
// Don't send text response if audio was sent successfully
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the message with the final response
|
||||
formattedResponse := formatResponseWithURLs(res.Response, urls)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"react-dom": "^19.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
@@ -139,7 +139,7 @@
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.35.0", "", {}, "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw=="],
|
||||
"@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
|
||||
@@ -501,6 +501,8 @@
|
||||
|
||||
"@types/babel__traverse/@babel/types": ["@babel/types@7.27.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg=="],
|
||||
|
||||
"eslint/@eslint/js": ["@eslint/js@9.35.0", "", {}, "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw=="],
|
||||
|
||||
"eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.26.10", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.10", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.10", "@babel/parser": "^7.26.10", "@babel/template": "^7.26.9", "@babel/traverse": "^7.26.10", "@babel/types": "^7.26.10", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ=="],
|
||||
|
||||
"tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="],
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"highlight.js": "^11.11.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@eslint/js": "^9.37.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
|
||||
@@ -5,7 +5,7 @@ import ConfigForm from './ConfigForm';
|
||||
* ActionForm component for configuring an action
|
||||
* Renders action configuration forms based on field group metadata
|
||||
*/
|
||||
const ActionForm = ({ actions = [], onChange, onRemove, onAdd, fieldGroups = [] }) => {
|
||||
const ActionForm = ({ actions = [], onChange, onRemove, onAdd, onPlay, fieldGroups = [] }) => {
|
||||
const handleActionChange = (index, updatedAction) => {
|
||||
onChange(index, updatedAction);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ const ActionForm = ({ actions = [], onChange, onRemove, onAdd, fieldGroups = []
|
||||
onChange={handleActionChange}
|
||||
onRemove={onRemove}
|
||||
onAdd={onAdd}
|
||||
onPlay={onPlay}
|
||||
itemType="action"
|
||||
typeField="name"
|
||||
addButtonText="Add Action"
|
||||
|
||||
@@ -10,6 +10,7 @@ import FormFieldDefinition from './common/FormFieldDefinition';
|
||||
* @param {Function} props.onChange - Callback when an item changes
|
||||
* @param {Function} props.onRemove - Callback when an item is removed
|
||||
* @param {Function} props.onAdd - Callback when a new item is added
|
||||
* @param {Function} props.onPlay - Callback when a play button is clicked
|
||||
* @param {String} props.itemType - Type of items being configured ('action', 'connector', etc.)
|
||||
* @param {String} props.typeField - The field name that determines the item's type (e.g., 'name' for actions, 'type' for connectors)
|
||||
* @param {String} props.addButtonText - Text for the add button
|
||||
@@ -19,8 +20,9 @@ const ConfigForm = ({
|
||||
items = [],
|
||||
fieldGroups = [],
|
||||
onChange,
|
||||
onRemove,
|
||||
onRemove,
|
||||
onAdd,
|
||||
onPlay,
|
||||
itemType = 'item',
|
||||
typeField = 'type',
|
||||
addButtonText = 'Add Item',
|
||||
@@ -89,15 +91,25 @@ const ConfigForm = ({
|
||||
|
||||
return (
|
||||
<div key={index} className="config-item mb-4 card">
|
||||
<div className="config-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h4 style={{ margin: 0 }}>{itemTypeLabel} #{index + 1}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn delete-btn"
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
<i className="fas fa-times"></i>
|
||||
</button>
|
||||
<div className="config-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '1rem' }}>
|
||||
<h4 style={{ margin: 0, flex: 1 }}>{itemTypeLabel} #{index + 1}</h4>
|
||||
{/* Render Play button if onPlay handler is provided */}
|
||||
{onPlay && (
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn"
|
||||
onClick={() => onPlay(index)}
|
||||
>
|
||||
<i className="fas fa-play"></i>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="action-btn delete-btn"
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
<i className="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="config-type mb-3">
|
||||
|
||||
@@ -35,6 +35,18 @@ const ActionsSection = ({ formData, setFormData, metadata }) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Handle the play button and open the action playground
|
||||
const handleActionPlay = (index) => {
|
||||
const action = formData.actions[index];
|
||||
const searchParams = new URLSearchParams({
|
||||
action: action.name,
|
||||
config: action.config
|
||||
});
|
||||
|
||||
// Open in new tab while staying in React Router context
|
||||
window.open(`/app/actions-playground?${searchParams.toString()}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="actions-section">
|
||||
<h3>Actions</h3>
|
||||
@@ -47,6 +59,7 @@ const ActionsSection = ({ formData, setFormData, metadata }) => {
|
||||
onChange={handleActionChange}
|
||||
onRemove={handleActionRemove}
|
||||
onAdd={handleAddAction}
|
||||
onPlay={handleActionPlay}
|
||||
fieldGroups={metadata?.actions || []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||
import { useOutletContext, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { actionApi, agentApi } from '../utils/api';
|
||||
import FormFieldDefinition from '../components/common/FormFieldDefinition';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
@@ -10,6 +10,7 @@ hljs.registerLanguage('json', json);
|
||||
function ActionsPlayground() {
|
||||
const { showToast } = useOutletContext();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [actions, setActions] = useState([]);
|
||||
const [selectedAction, setSelectedAction] = useState('');
|
||||
const [configJson, setConfigJson] = useState('{}');
|
||||
@@ -47,6 +48,29 @@ function ActionsPlayground() {
|
||||
fetchActions();
|
||||
}, []);
|
||||
|
||||
// If query param 'action' is present, pre-select it once actions are loaded
|
||||
useEffect(() => {
|
||||
if (loadingActions) return;
|
||||
const queryAction = searchParams.get('action');
|
||||
if (!queryAction) return;
|
||||
if (!selectedAction && actions.includes(queryAction)) {
|
||||
setSelectedAction(queryAction);
|
||||
}
|
||||
}, [loadingActions, actions, searchParams, selectedAction]);
|
||||
|
||||
// If query param 'config' is present, pre-fill the configuration JSON
|
||||
useEffect(() => {
|
||||
const queryConfig = searchParams.get('config');
|
||||
if (!queryConfig) return;
|
||||
try {
|
||||
const parsed = JSON.parse(queryConfig);
|
||||
setConfigJson(JSON.stringify(parsed, null, 2));
|
||||
} catch (err) {
|
||||
console.error('Invalid config query parameter:', err);
|
||||
showToast('Invalid config query parameter. Expected JSON.', 'error');
|
||||
}
|
||||
}, [searchParams, showToast]);
|
||||
|
||||
// Fetch agent metadata on mount
|
||||
useEffect(() => {
|
||||
const fetchAgentMetadata = async () => {
|
||||
|
||||
@@ -216,6 +216,7 @@ function AgentStatus() {
|
||||
const [observableMap, setObservableMap] = useState({});
|
||||
const [observableTree, setObservableTree] = useState([]);
|
||||
const [expandedCards, setExpandedCards] = useState(new Map());
|
||||
const [clearLoading, setClearLoading] = useState(false);
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
@@ -342,6 +343,26 @@ function AgentStatus() {
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const handleClearObservables = async () => {
|
||||
if (clearLoading) return;
|
||||
setClearLoading(true);
|
||||
try {
|
||||
const resp = await fetch(`/api/agent/${name}/observables`, { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to clear observables, status:', resp.status);
|
||||
} else {
|
||||
// Clear local state immediately
|
||||
setObservableMap({});
|
||||
setObservableTree([]);
|
||||
setExpandedCards(new Map());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error clearing observables:', e);
|
||||
} finally {
|
||||
setClearLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to safely convert any value to a displayable string
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -435,7 +456,17 @@ function AgentStatus() {
|
||||
</div>
|
||||
{observableTree.length > 0 && (
|
||||
<div>
|
||||
<h2>Observable Updates</h2>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0 }}>Observable Updates</h2>
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
onClick={handleClearObservables}
|
||||
disabled={clearLoading}
|
||||
title="Clear observable history"
|
||||
>
|
||||
{clearLoading ? 'Clearing…' : 'Clear history'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
|
||||
Drill down into what the agent is doing and thinking when activated by a connector
|
||||
</div>
|
||||
|
||||
@@ -258,6 +258,7 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
||||
})
|
||||
})
|
||||
|
||||
// API endpoint to retrieve agent observables
|
||||
webapp.Get("/api/agent/:name/observables", func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
agent := pool.GetAgent(name)
|
||||
@@ -273,6 +274,17 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
|
||||
})
|
||||
})
|
||||
|
||||
// API endpoint to clear agent observables
|
||||
webapp.Delete("/api/agent/:name/observables", func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
agent := pool.GetAgent(name)
|
||||
if agent == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Agent not found"})
|
||||
}
|
||||
agent.Observer().ClearHistory()
|
||||
return c.JSON(fiber.Map{"Name": name, "cleared": true})
|
||||
})
|
||||
|
||||
webapp.Post("/settings/import", app.ImportAgent(pool))
|
||||
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user