mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-04 07:56:16 -04:00
Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 419a4363c3 | |||
| 292d0c9c19 | |||
| 1147e02844 | |||
| c8e83dc4b9 | |||
| 8b5188c35b | |||
| 55b631bc51 | |||
| c1e2eeb8af | |||
| 50f168f322 | |||
| b5dacb0e4f | |||
| 0762d6fabb | |||
| 6dc515bae5 | |||
| 9110e9cfdb | |||
| 9965e3df36 | |||
| da9e8d7c20 | |||
| 7f2e52c0c8 | |||
| 6a33484fce | |||
| bdf12e4c3d | |||
| 5743d6f757 | |||
| 0845e9dc81 | |||
| ef2a6e2296 | |||
| 272c54f851 | |||
| 775b1a9ab8 | |||
| 673909918a | |||
| 807b7e756b | |||
| c75da2235d | |||
| 80a12f6662 | |||
| 1128615140 | |||
| 8847bfe0fc | |||
| 25ce5475de | |||
| 2a4bc0eb0a | |||
| d0bfa01494 | |||
| 57023d6386 | |||
| 1c3e9b36e7 | |||
| 4a3ee02081 | |||
| d804ef66e5 | |||
| cea3b7b111 | |||
| 1d574069f2 | |||
| b09da13111 | |||
| ba2eafe4b2 | |||
| cbe4cd62ca | |||
| 4f4ad5069d | |||
| 0950791be6 | |||
| f47d5d0e01 | |||
| e3d4ac3d0b | |||
| c98178d41d | |||
| 4e89c8aac0 | |||
| c3bd2bee42 | |||
| 9f358abb54 | |||
| 456c32c284 | |||
| b527cbc332 | |||
| 7cc0c3e85b | |||
| 73ce451c13 | |||
| 72a7807a81 | |||
| 16775f356a | |||
| a363682b39 | |||
| 2288e7f08d | |||
| c703495e8c | |||
| 73c1158aa2 | |||
| a1036a166b | |||
| 9a669e2552 | |||
| cfdefcd075 | |||
| ec3f302cdf | |||
| 345dd672d2 | |||
| 7e50afde73 | |||
| e33046db03 | |||
| 9a33537ae0 | |||
| c7040585d3 | |||
| 85823961f3 | |||
| ef0e6d2025 | |||
| 4687efb502 | |||
| 9829930451 | |||
| eaf81438fa | |||
| 8141defbaf | |||
| a1667b2227 | |||
| 05f21c6c80 | |||
| f6ab437796 | |||
| 9405fe3825 | |||
| bef63357be | |||
| 0fec0d39b7 | |||
| 8709422e05 | |||
| 229aab41f8 | |||
| 853a168773 | |||
| fa963660e0 | |||
| fbb098e996 | |||
| b124244b0c | |||
| c7d1b58340 | |||
| 9971819350 | |||
| e3769d3e1f | |||
| f777ce2992 | |||
| 02eda4efb3 | |||
| 1b3420c857 | |||
| 2f537e72bb | |||
| 4afdb6f813 | |||
| c610c2c6ca | |||
| 047a324e0e | |||
| e37283cb11 | |||
| d6f10b28c1 | |||
| c082b42395 | |||
| 252ee2aff6 | |||
| fffc1f8647 | |||
| dc1653294d | |||
| 3862c3c0eb | |||
| 756c46b526 | |||
| 73a6be8264 | |||
| 84f21c6ab4 | |||
| a0ab067bf7 | |||
| c719cbd544 | |||
| 6fa73262fc | |||
| 558fa396a8 | |||
| 33ffe06510 | |||
| a5a602c5b0 | |||
| 500862b768 | |||
| b06522d1ec | |||
| 0c312c9caa |
@@ -16,11 +16,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24
|
||||
- name: Run GoReleaser
|
||||
|
||||
@@ -11,10 +11,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
containerImages:
|
||||
runs-on: ubuntu-latest
|
||||
#runs-on: ubuntu-latest
|
||||
runs-on: arc-runner-localagent
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -57,7 +58,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
with:
|
||||
images: quay.io/mudler/localagi
|
||||
tags: |
|
||||
@@ -83,84 +84,12 @@ 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:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -203,7 +132,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
with:
|
||||
images: quay.io/mudler/localagi-sshbox
|
||||
tags: |
|
||||
|
||||
+13
-37
@@ -1,4 +1,4 @@
|
||||
name: Run Go Tests
|
||||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -15,44 +15,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- run: |
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt-get update
|
||||
sudo apt-get install ca-certificates curl
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin make
|
||||
docker version
|
||||
|
||||
docker run --rm hello-world
|
||||
- uses: actions/setup-go@v5
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo apt-get clean
|
||||
docker system prune -af || true
|
||||
df -h
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests
|
||||
#sudo mv coverage/coverage.txt coverage.txt
|
||||
#sudo chmod 777 coverage.txt
|
||||
|
||||
# - name: Upload coverage to Codecov
|
||||
# uses: codecov/codecov-action@v4
|
||||
# with:
|
||||
# token: ${{ secrets.CODECOV_TOKEN }}
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
- run: |
|
||||
make tests-e2e
|
||||
@@ -8,3 +8,5 @@ LocalAGI
|
||||
**/.env
|
||||
.vscode
|
||||
volumes/
|
||||
example/scheduler/scheduler
|
||||
example/scheduler/example_tasks.json
|
||||
|
||||
@@ -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"]
|
||||
@@ -1,5 +1,5 @@
|
||||
# python
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y python3-dev portaudio19-dev ffmpeg build-essential
|
||||
|
||||
+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,23 +1,21 @@
|
||||
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 --label-filter="!E2E" --flake-attempts=5 --fail-fast -v -r ./...
|
||||
|
||||
run-nokb:
|
||||
$(MAKE) run KBDISABLEINDEX=true
|
||||
|
||||
webui/react-ui/dist:
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
|
||||
.PHONY: build
|
||||
build: webui/react-ui/dist
|
||||
@@ -25,7 +23,7 @@ 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 .
|
||||
@@ -33,8 +31,5 @@ build-image:
|
||||
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
|
||||
tests-e2e: prepare-tests
|
||||
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 --label-filter="E2E" --flake-attempts=5 --fail-fast -v -r ./tests/e2e/...
|
||||
|
||||
@@ -18,7 +18,7 @@ Try on [ and a web browser.
|
||||
|
||||
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
|
||||
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and give it skills with [skillserver](https://github.com/mudler/skillserver). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
|
||||
|
||||
## 🛡️ Take Back Your Privacy
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewGoal creates a new intention action
|
||||
// The inention action is special as it tries to identify
|
||||
// a tool to use and a reasoning over to use it
|
||||
func NewGoal() *GoalAction {
|
||||
return &GoalAction{}
|
||||
}
|
||||
|
||||
type GoalAction struct {
|
||||
}
|
||||
type GoalResponse struct {
|
||||
Goal string `json:"goal"`
|
||||
Achieved bool `json:"achieved"`
|
||||
}
|
||||
|
||||
func (a *GoalAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *GoalAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *GoalAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "goal",
|
||||
Description: "Check if the goal is achieved",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal to check if it is achieved.",
|
||||
},
|
||||
"achieved": {
|
||||
Type: jsonschema.Boolean,
|
||||
Description: "Whether the goal is achieved",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal", "achieved"},
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewIntention creates a new intention action
|
||||
// The inention action is special as it tries to identify
|
||||
// a tool to use and a reasoning over to use it
|
||||
func NewIntention(s ...string) *IntentAction {
|
||||
return &IntentAction{tools: s}
|
||||
}
|
||||
|
||||
type IntentAction struct {
|
||||
tools []string
|
||||
}
|
||||
type IntentResponse struct {
|
||||
Tool string `json:"tool"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *IntentAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *IntentAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *IntentAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "pick_tool",
|
||||
Description: "Pick a tool",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "A detailed reasoning on why you want to call this tool.",
|
||||
},
|
||||
"tool": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The tool you want to use",
|
||||
Enum: a.tools,
|
||||
},
|
||||
},
|
||||
Required: []string{"tool", "reasoning"},
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const ConversationActionName = "new_conversation"
|
||||
const ConversationActionName = "send_message"
|
||||
|
||||
func NewConversation() *ConversationAction {
|
||||
return &ConversationAction{}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// PlanActionName is the name of the plan action
|
||||
// used by the LLM to schedule more actions
|
||||
const PlanActionName = "plan"
|
||||
|
||||
func NewPlan(plannableActions []string) *PlanAction {
|
||||
return &PlanAction{
|
||||
plannables: plannableActions,
|
||||
}
|
||||
}
|
||||
|
||||
type PlanAction struct {
|
||||
plannables []string
|
||||
}
|
||||
|
||||
type PlanResult struct {
|
||||
Subtasks []PlanSubtask `json:"subtasks"`
|
||||
Goal string `json:"goal"`
|
||||
}
|
||||
type PlanSubtask struct {
|
||||
Action string `json:"action"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *PlanAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *PlanAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *PlanAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: PlanActionName,
|
||||
Description: "Use it for situations that involves doing more actions in sequence.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"subtasks": {
|
||||
Type: jsonschema.Array,
|
||||
Description: "The subtasks to be executed",
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"action": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The action to call",
|
||||
Enum: a.plannables,
|
||||
},
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The reasoning for calling this action",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal of this plan",
|
||||
},
|
||||
},
|
||||
Required: []string{"subtasks", "goal"},
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// NewReasoning creates a new reasoning action
|
||||
// The reasoning action is special as it tries to force the LLM
|
||||
// to think about what to do next
|
||||
func NewReasoning() *ReasoningAction {
|
||||
return &ReasoningAction{}
|
||||
}
|
||||
|
||||
type ReasoningAction struct{}
|
||||
|
||||
type ReasoningResponse struct {
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
return types.ActionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ReasoningAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "pick_action",
|
||||
Description: "try to understand what's the best thing to do and pick an action with a reasoning",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
Description: "A detailed reasoning on what would you do in this situation.",
|
||||
},
|
||||
},
|
||||
Required: []string{"reasoning"},
|
||||
}
|
||||
}
|
||||
+127
-52
@@ -6,19 +6,24 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
ReminderActionName = "set_reminder"
|
||||
ListRemindersName = "list_reminders"
|
||||
RemoveReminderName = "remove_reminder"
|
||||
RecurringReminderActionName = "set_recurring_task"
|
||||
OneTimeReminderActionName = "set_onetime_task"
|
||||
ListRemindersName = "list_tasks"
|
||||
RemoveReminderName = "remove_task"
|
||||
)
|
||||
|
||||
func NewReminder() *ReminderAction {
|
||||
return &ReminderAction{}
|
||||
func NewRecurringReminder() *RecurringReminderAction {
|
||||
return &RecurringReminderAction{}
|
||||
}
|
||||
|
||||
func NewOneTimeReminder() *OneTimeReminderAction {
|
||||
return &OneTimeReminderAction{}
|
||||
}
|
||||
|
||||
func NewListReminders() *ListRemindersAction {
|
||||
@@ -29,7 +34,8 @@ func NewRemoveReminder() *RemoveReminderAction {
|
||||
return &RemoveReminderAction{}
|
||||
}
|
||||
|
||||
type ReminderAction struct{}
|
||||
type RecurringReminderAction struct{}
|
||||
type OneTimeReminderAction struct{}
|
||||
type ListRemindersAction struct{}
|
||||
type RemoveReminderAction struct{}
|
||||
|
||||
@@ -37,46 +43,87 @@ type RemoveReminderParams struct {
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.ReminderActionResponse{}
|
||||
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.RecurringReminderParams{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Validate the cron expression
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err = parser.Parse(result.CronExpr)
|
||||
task, err := scheduler.NewTask(
|
||||
sharedState.AgentName,
|
||||
result.Message,
|
||||
scheduler.ScheduleTypeCron,
|
||||
result.CronExpr,
|
||||
)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Calculate next run time
|
||||
now := time.Now()
|
||||
schedule, _ := parser.Parse(result.CronExpr) // We can ignore the error since we validated above
|
||||
nextRun := schedule.Next(now)
|
||||
task.Metadata["reminder_type"] = "user_created"
|
||||
|
||||
// Set the reminder details
|
||||
result.LastRun = now
|
||||
result.NextRun = nextRun
|
||||
// IsRecurring is set by the user through the action parameters
|
||||
|
||||
// Store the reminder in the shared state
|
||||
if sharedState.Reminders == nil {
|
||||
sharedState.Reminders = make([]types.ReminderActionResponse, 0)
|
||||
err = sharedState.Scheduler.CreateTask(task)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
sharedState.Reminders = append(sharedState.Reminders, result)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: "Reminder set successfully",
|
||||
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
|
||||
Metadata: map[string]interface{}{
|
||||
"reminder": result,
|
||||
"task_id": task.ID,
|
||||
"message": result.Message,
|
||||
"next_run": task.NextRun,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.OneTimeReminderParams{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
// Validate the delay parses correctly before creating the task
|
||||
_, err = scheduler.ParseDuration(result.Delay)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid delay format, expected a duration like '30m', '2h', '1d', '1d12h': %w", err)
|
||||
}
|
||||
|
||||
task, err := scheduler.NewTask(
|
||||
sharedState.AgentName,
|
||||
result.Message,
|
||||
scheduler.ScheduleTypeOnce,
|
||||
result.Delay,
|
||||
)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
task.Metadata["reminder_type"] = "user_created"
|
||||
|
||||
err = sharedState.Scheduler.CreateTask(task)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("One-time reminder set in %s (at %s, ID: %s)", result.Delay, task.NextRun.Format(time.RFC3339), task.ID),
|
||||
Metadata: map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"message": result.Message,
|
||||
"next_run": task.NextRun,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders set",
|
||||
}, nil
|
||||
@@ -84,22 +131,25 @@ func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentS
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("Current reminders:\n")
|
||||
for i, reminder := range sharedState.Reminders {
|
||||
|
||||
for i, task := range tasks {
|
||||
status := "one-time"
|
||||
if reminder.IsRecurring {
|
||||
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
|
||||
status = "recurring"
|
||||
}
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
|
||||
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
|
||||
i+1,
|
||||
reminder.Message,
|
||||
reminder.NextRun.Format(time.RFC3339),
|
||||
status))
|
||||
task.Prompt,
|
||||
task.NextRun.Format(time.RFC3339),
|
||||
status,
|
||||
task.ID))
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: result.String(),
|
||||
Metadata: map[string]interface{}{
|
||||
"reminders": sharedState.Reminders,
|
||||
"tasks": tasks,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -111,7 +161,12 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders to remove",
|
||||
}, nil
|
||||
@@ -119,23 +174,29 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
|
||||
|
||||
// Convert from 1-based index to 0-based
|
||||
index := removeParams.Index - 1
|
||||
if index < 0 || index >= len(sharedState.Reminders) {
|
||||
if index < 0 || index >= len(tasks) {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
|
||||
}
|
||||
|
||||
// Remove the reminder
|
||||
removed := sharedState.Reminders[index]
|
||||
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)
|
||||
task := tasks[index]
|
||||
err = sharedState.Scheduler.DeleteTask(task.ID)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
|
||||
Result: fmt.Sprintf("Removed reminder: %s", task.Prompt),
|
||||
Metadata: map[string]interface{}{
|
||||
"removed_reminder": removed,
|
||||
"removed_task_id": task.ID,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Plannable() bool {
|
||||
func (a *RecurringReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -147,10 +208,10 @@ func (a *RemoveReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *ReminderAction) Definition() types.ActionDefinition {
|
||||
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: ReminderActionName,
|
||||
Description: "Set a reminder for the agent to wake up and perform a task based on a cron schedule. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
|
||||
Name: RecurringReminderActionName,
|
||||
Description: "Set a recurring reminder for the agent to wake up and perform a task on a cron schedule. The reminder will keep repeating. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
@@ -158,14 +219,28 @@ func (a *ReminderAction) Definition() types.ActionDefinition {
|
||||
},
|
||||
"cron_expr": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'second minute hour day month weekday'",
|
||||
},
|
||||
"is_recurring": {
|
||||
Type: jsonschema.Boolean,
|
||||
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
|
||||
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
|
||||
},
|
||||
},
|
||||
Required: []string{"message", "cron_expr", "is_recurring"},
|
||||
Required: []string{"message", "cron_expr"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: OneTimeReminderActionName,
|
||||
Description: "Set a one-time reminder for the agent to wake up and perform a task after a delay. The reminder triggers only once and is then automatically removed. Use this when asked to do something 'in X minutes/hours/days'. Examples: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The message or task to be reminded about",
|
||||
},
|
||||
"delay": {
|
||||
Type: jsonschema.String,
|
||||
Description: "How long to wait before triggering. Use Go duration format: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
|
||||
},
|
||||
},
|
||||
Required: []string{"message", "delay"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// ReplyActionName is the name of the reply action
|
||||
// used by the LLM to reply to the user without
|
||||
// any additional processing
|
||||
const ReplyActionName = "reply"
|
||||
|
||||
func NewReply() *ReplyAction {
|
||||
return &ReplyAction{}
|
||||
}
|
||||
|
||||
type ReplyAction struct{}
|
||||
|
||||
type ReplyResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (string, error) {
|
||||
return "no-op", nil
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Plannable() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *ReplyAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: ReplyActionName,
|
||||
Description: "Use this tool to reply to the user once we have all the informations we need.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The message to reply with",
|
||||
},
|
||||
},
|
||||
Required: []string{"message"},
|
||||
}
|
||||
}
|
||||
+10
-509
@@ -1,155 +1,18 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const parameterReasoningPrompt = `You are tasked with generating the optimal parameters for the action "%s". The action requires the following parameters:
|
||||
%s
|
||||
|
||||
Your task is to:
|
||||
1. Generate the best possible values for each required parameter
|
||||
2. If the parameter requires code, provide complete, working code
|
||||
3. If the parameter requires text or documentation, provide comprehensive, well-structured content
|
||||
4. Ensure all parameters are complete and ready to be used
|
||||
|
||||
Focus on quality and completeness. Do not explain your reasoning or analyze the action's purpose - just provide the best possible parameter values.`
|
||||
|
||||
type decisionResult struct {
|
||||
actionParams types.ActionParams
|
||||
message string
|
||||
actionName string
|
||||
}
|
||||
|
||||
// decision forces the agent to take one of the available actions
|
||||
func (a *Agent) decision(
|
||||
job *types.Job,
|
||||
conversation []openai.ChatCompletionMessage,
|
||||
tools []openai.Tool, toolchoice string, maxRetries int) (*decisionResult, error) {
|
||||
|
||||
var choice *openai.ToolChoice
|
||||
|
||||
if toolchoice != "" {
|
||||
choice = &openai.ToolChoice{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: openai.ToolFunction{Name: toolchoice},
|
||||
}
|
||||
}
|
||||
|
||||
decision := openai.ChatCompletionRequest{
|
||||
Model: a.options.LLMAPI.Model,
|
||||
Messages: conversation,
|
||||
Tools: tools,
|
||||
}
|
||||
|
||||
if choice != nil {
|
||||
decision.ToolChoice = *choice
|
||||
}
|
||||
|
||||
var obs *types.Observable
|
||||
if job.Obs != nil {
|
||||
obs = a.observer.NewObservable()
|
||||
obs.Name = "decision"
|
||||
obs.ParentID = job.Obs.ID
|
||||
obs.Icon = "brain"
|
||||
obs.Creation = &types.Creation{
|
||||
ChatCompletionRequest: &decision,
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempts := 0; attempts < maxRetries; attempts++ {
|
||||
resp, err := a.client.CreateChatCompletion(job.GetContext(), decision)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", err)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
Error: err.Error(),
|
||||
})
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
jsonResp, _ := json.Marshal(resp)
|
||||
xlog.Debug("Decision response", "response", string(jsonResp))
|
||||
|
||||
if obs != nil {
|
||||
obs.AddProgress(types.Progress{
|
||||
ChatCompletionResponse: &resp,
|
||||
})
|
||||
}
|
||||
|
||||
if len(resp.Choices) != 1 {
|
||||
lastErr = fmt.Errorf("no choices: %d", len(resp.Choices))
|
||||
xlog.Warn("Attempt to make a decision failed", "attempt", attempts+1, "error", lastErr)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
msg := resp.Choices[0].Message
|
||||
if len(msg.ToolCalls) != 1 {
|
||||
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
||||
xlog.Error("Error saving conversation", "error", err)
|
||||
}
|
||||
|
||||
if obs != nil {
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
return &decisionResult{message: msg.Content}, nil
|
||||
}
|
||||
|
||||
params := types.ActionParams{}
|
||||
if err := params.Read(msg.ToolCalls[0].Function.Arguments); err != nil {
|
||||
lastErr = err
|
||||
xlog.Warn("Attempt to parse action parameters failed", "attempt", attempts+1, "error", err)
|
||||
|
||||
if obs != nil {
|
||||
obs.Progress[len(obs.Progress)-1].Error = lastErr.Error()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.saveConversation(append(conversation, msg), "decision"); err != nil {
|
||||
xlog.Error("Error saving conversation", "error", err)
|
||||
}
|
||||
|
||||
if obs != nil {
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
return &decisionResult{actionParams: params, actionName: msg.ToolCalls[0].Function.Name, message: msg.Content}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to make a decision after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
type Messages []openai.ChatCompletionMessage
|
||||
|
||||
func (m Messages) ToOpenAI() []openai.ChatCompletionMessage {
|
||||
@@ -228,209 +91,10 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Messages) IsLastMessageFromRole(role string) bool {
|
||||
if len(m) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return m[len(m)-1].Role == role
|
||||
}
|
||||
|
||||
func (a *Agent) generateParameters(job *types.Job, pickTemplate string, act types.Action, c []openai.ChatCompletionMessage, reasoning string, maxAttempts int) (*decisionResult, error) {
|
||||
if act == nil {
|
||||
return nil, fmt.Errorf("action is nil")
|
||||
}
|
||||
if len(act.Definition().Properties) > 0 {
|
||||
xlog.Debug("Action has properties", "action", act.Definition().Name, "properties", act.Definition().Properties)
|
||||
} else {
|
||||
xlog.Debug("Action has no properties", "action", act.Definition().Name)
|
||||
return &decisionResult{actionParams: types.ActionParams{}}, nil
|
||||
}
|
||||
|
||||
stateHUD, err := renderTemplate(pickTemplate, a.prepareHUD(), a.availableActions(), reasoning)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conversation := c
|
||||
if !Messages(c).Exist(stateHUD) && a.options.enableHUD {
|
||||
conversation = append([]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: stateHUD,
|
||||
},
|
||||
}, conversation...)
|
||||
}
|
||||
|
||||
cc := conversation
|
||||
if a.options.forceReasoning {
|
||||
// First, get the LLM to reason about optimal parameter usage
|
||||
parameterReasoningPrompt := fmt.Sprintf(parameterReasoningPrompt,
|
||||
act.Definition().Name,
|
||||
formatProperties(act.Definition().Properties))
|
||||
|
||||
// Get initial reasoning about parameters using askLLM
|
||||
paramReasoningMsg, err := a.askLLM(job.GetContext(),
|
||||
append(conversation, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: parameterReasoningPrompt,
|
||||
}),
|
||||
maxAttempts,
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Warn("Failed to get parameter reasoning", "error", err)
|
||||
}
|
||||
|
||||
// Combine original reasoning with parameter-specific reasoning
|
||||
enhancedReasoning := reasoning
|
||||
if paramReasoningMsg.Content != "" {
|
||||
enhancedReasoning = fmt.Sprintf("%s\n\nParameter Analysis:\n%s", reasoning, paramReasoningMsg.Content)
|
||||
}
|
||||
|
||||
cc = append(conversation, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("The agent decided to use the tool %s with the following reasoning: %s", act.Definition().Name, enhancedReasoning),
|
||||
})
|
||||
}
|
||||
|
||||
var result *decisionResult
|
||||
var attemptErr error
|
||||
|
||||
for attempts := 0; attempts < maxAttempts; attempts++ {
|
||||
result, attemptErr = a.decision(job,
|
||||
cc,
|
||||
a.availableActions().ToTools(),
|
||||
act.Definition().Name.String(),
|
||||
maxAttempts,
|
||||
)
|
||||
if attemptErr == nil && result.actionParams != nil {
|
||||
return result, nil
|
||||
}
|
||||
xlog.Warn("Attempt to generate parameters failed", "attempt", attempts+1, "error", attemptErr)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to generate parameters after %d attempts: %w", maxAttempts, attemptErr)
|
||||
}
|
||||
|
||||
// Helper function to format properties for the prompt
|
||||
func formatProperties(props map[string]jsonschema.Definition) string {
|
||||
var result strings.Builder
|
||||
for name, prop := range props {
|
||||
result.WriteString(fmt.Sprintf("- %s: %s\n", name, prop.Description))
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func (a *Agent) handlePlanning(ctx context.Context, job *types.Job, chosenAction types.Action, actionParams types.ActionParams, reasoning string, pickTemplate string, conv Messages) (Messages, error) {
|
||||
// Planning: run all the actions in sequence
|
||||
if !chosenAction.Definition().Name.Is(action.PlanActionName) {
|
||||
xlog.Debug("no plan action")
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
xlog.Debug("[planning]...")
|
||||
planResult := action.PlanResult{}
|
||||
if err := actionParams.Unmarshal(&planResult); err != nil {
|
||||
return conv, fmt.Errorf("error unmarshalling plan result: %w", err)
|
||||
}
|
||||
|
||||
stateResult := types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: chosenAction,
|
||||
Params: actionParams,
|
||||
Reasoning: reasoning,
|
||||
},
|
||||
ActionResult: types.ActionResult{
|
||||
Result: fmt.Sprintf("planning %s, subtasks: %+v", planResult.Goal, planResult.Subtasks),
|
||||
},
|
||||
}
|
||||
job.Result.SetResult(stateResult)
|
||||
job.CallbackWithResult(stateResult)
|
||||
|
||||
xlog.Info("[Planning] starts", "agent", a.Character.Name, "goal", planResult.Goal)
|
||||
for _, s := range planResult.Subtasks {
|
||||
xlog.Info("[Planning] subtask", "agent", a.Character.Name, "action", s.Action, "reasoning", s.Reasoning)
|
||||
}
|
||||
|
||||
if len(planResult.Subtasks) == 0 {
|
||||
return conv, fmt.Errorf("no subtasks")
|
||||
}
|
||||
|
||||
// Execute all subtasks in sequence
|
||||
for _, subtask := range planResult.Subtasks {
|
||||
xlog.Info("[subtask] Generating parameters",
|
||||
"agent", a.Character.Name,
|
||||
"action", subtask.Action,
|
||||
"reasoning", reasoning,
|
||||
)
|
||||
|
||||
subTaskAction := a.availableActions().Find(subtask.Action)
|
||||
if subTaskAction == nil {
|
||||
xlog.Error("Action not found: %s", subtask.Action)
|
||||
return conv, fmt.Errorf("action %s not found", subtask.Action)
|
||||
}
|
||||
|
||||
subTaskReasoning := fmt.Sprintf("%s Overall goal is: %s", subtask.Reasoning, planResult.Goal)
|
||||
|
||||
params, err := a.generateParameters(job, pickTemplate, subTaskAction, conv, subTaskReasoning, maxRetries)
|
||||
if err != nil {
|
||||
xlog.Error("error generating action's parameters", "error", err)
|
||||
return conv, fmt.Errorf("error generating action's parameters: %w", err)
|
||||
|
||||
}
|
||||
actionParams = params.actionParams
|
||||
|
||||
if !job.Callback(types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: subTaskAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
}) {
|
||||
job.Result.SetResult(types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: chosenAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
},
|
||||
ActionResult: types.ActionResult{
|
||||
Result: "stopped by callback",
|
||||
},
|
||||
})
|
||||
job.Result.Conversation = conv
|
||||
job.Result.Finish(nil)
|
||||
break
|
||||
}
|
||||
|
||||
result, err := a.runAction(job, subTaskAction, actionParams)
|
||||
if err != nil {
|
||||
xlog.Error("error running action", "error", err)
|
||||
return conv, fmt.Errorf("error running action: %w", err)
|
||||
}
|
||||
|
||||
stateResult := types.ActionState{
|
||||
ActionCurrentState: types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: subTaskAction,
|
||||
Params: actionParams,
|
||||
Reasoning: subTaskReasoning,
|
||||
},
|
||||
ActionResult: result,
|
||||
}
|
||||
job.Result.SetResult(stateResult)
|
||||
job.CallbackWithResult(stateResult)
|
||||
xlog.Debug("[subtask] Action executed", "agent", a.Character.Name, "action", subTaskAction.Definition().Name, "result", result)
|
||||
conv = a.addFunctionResultToConversation(job.GetContext(), subTaskAction, actionParams, result, conv)
|
||||
}
|
||||
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
// getAvailableActionsForJob returns available actions including user-defined ones for a specific job
|
||||
func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
|
||||
// Start with regular available actions
|
||||
baseActions := a.availableActions()
|
||||
baseActions := a.availableActions(job)
|
||||
|
||||
// Add user-defined actions from the job
|
||||
userTools := job.GetUserTools()
|
||||
@@ -443,27 +107,11 @@ func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
|
||||
return baseActions
|
||||
}
|
||||
|
||||
func (a *Agent) availableActions() types.Actions {
|
||||
func (a *Agent) availableActions(j *types.Job) types.Actions {
|
||||
// defaultActions := append(a.options.userActions, action.NewReply())
|
||||
|
||||
addPlanAction := func(actions types.Actions) types.Actions {
|
||||
if !a.options.canPlan {
|
||||
return actions
|
||||
}
|
||||
plannablesActions := []string{}
|
||||
for _, a := range actions {
|
||||
if a.Plannable() {
|
||||
plannablesActions = append(plannablesActions, a.Definition().Name.String())
|
||||
}
|
||||
}
|
||||
planAction := action.NewPlan(plannablesActions)
|
||||
actions = append(actions, planAction)
|
||||
return actions
|
||||
}
|
||||
|
||||
defaultActions := append(a.mcpActions, a.options.userActions...)
|
||||
|
||||
if a.options.initiateConversations && a.selfEvaluationInProgress { // && self-evaluation..
|
||||
defaultActions := slices.Clone(a.options.userActions)
|
||||
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
|
||||
acts := append(defaultActions, action.NewConversation())
|
||||
if a.options.enableHUD {
|
||||
acts = append(acts, action.NewState())
|
||||
@@ -472,7 +120,7 @@ func (a *Agent) availableActions() types.Actions {
|
||||
// acts = append(acts, action.NewStop())
|
||||
// }
|
||||
|
||||
return addPlanAction(acts)
|
||||
return acts
|
||||
}
|
||||
|
||||
if a.options.canStopItself {
|
||||
@@ -480,14 +128,14 @@ func (a *Agent) availableActions() types.Actions {
|
||||
if a.options.enableHUD {
|
||||
acts = append(acts, action.NewState())
|
||||
}
|
||||
return addPlanAction(acts)
|
||||
return acts
|
||||
}
|
||||
|
||||
if a.options.enableHUD {
|
||||
return addPlanAction(append(defaultActions, action.NewState()))
|
||||
return append(defaultActions, action.NewState())
|
||||
}
|
||||
|
||||
return addPlanAction(defaultActions)
|
||||
return defaultActions
|
||||
}
|
||||
|
||||
func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
|
||||
@@ -502,150 +150,3 @@ func (a *Agent) prepareHUD() (promptHUD *PromptHUD) {
|
||||
ShowCharacter: a.options.showCharacter,
|
||||
}
|
||||
}
|
||||
|
||||
// pickAction picks an action based on the conversation
|
||||
func (a *Agent) pickAction(job *types.Job, templ string, messages []openai.ChatCompletionMessage, maxRetries int) (types.Action, types.ActionParams, string, error) {
|
||||
c := messages
|
||||
|
||||
xlog.Debug("[pickAction] picking action starts", "messages", messages)
|
||||
|
||||
// Get available actions including user-defined ones
|
||||
availableActions := a.getAvailableActionsForJob(job)
|
||||
|
||||
// Identify the goal of this conversation
|
||||
|
||||
if !a.options.forceReasoning || job.ToolChoice != "" {
|
||||
xlog.Debug("not forcing reasoning", "forceReasoning", a.options.forceReasoning, "ToolChoice", job.ToolChoice)
|
||||
// We also could avoid to use functions here and get just a reply from the LLM
|
||||
// and then use the reply to get the action
|
||||
thought, err := a.decision(job,
|
||||
messages,
|
||||
availableActions.ToTools(),
|
||||
job.ToolChoice,
|
||||
maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
xlog.Debug("thought action Name", "actionName", thought.actionName)
|
||||
xlog.Debug("thought message", "message", thought.message)
|
||||
|
||||
// Find the action
|
||||
chosenAction := availableActions.Find(thought.actionName)
|
||||
if chosenAction == nil || thought.actionName == "" {
|
||||
xlog.Debug("no answer")
|
||||
|
||||
// LLM replied with an answer?
|
||||
//fmt.Errorf("no action found for intent:" + thought.actioName)
|
||||
return nil, nil, thought.message, nil
|
||||
}
|
||||
xlog.Debug(fmt.Sprintf("chosenAction: %v", chosenAction.Definition().Name))
|
||||
return chosenAction, thought.actionParams, thought.message, nil
|
||||
}
|
||||
|
||||
// Force the LLM to think and we extract a "reasoning" to pick a specific action and with which parameters
|
||||
xlog.Debug("[pickAction] forcing reasoning")
|
||||
|
||||
prompt, err := renderTemplate(templ, a.prepareHUD(), a.availableActions(), "")
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
// Get the LLM to think on what to do
|
||||
// and have a thought
|
||||
if !Messages(c).Exist(prompt) {
|
||||
c = append([]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
}, c...)
|
||||
}
|
||||
|
||||
// Create a detailed prompt for reasoning that includes available actions and their properties
|
||||
reasoningPrompt := "Analyze the current situation and determine the best course of action. Consider the following:\n\n"
|
||||
reasoningPrompt += "Available Actions:\n"
|
||||
for _, act := range a.availableActions() {
|
||||
reasoningPrompt += fmt.Sprintf("- %s: %s\n", act.Definition().Name, act.Definition().Description)
|
||||
if len(act.Definition().Properties) > 0 {
|
||||
reasoningPrompt += " Properties:\n"
|
||||
for name, prop := range act.Definition().Properties {
|
||||
reasoningPrompt += fmt.Sprintf(" - %s: %s\n", name, prop.Description)
|
||||
}
|
||||
}
|
||||
reasoningPrompt += "\n"
|
||||
}
|
||||
reasoningPrompt += "\nProvide a detailed reasoning about what action would be most appropriate in this situation and why. You can also just reply with a simple message by choosing the 'reply' or 'answer' action."
|
||||
|
||||
// Get reasoning using askLLM
|
||||
reasoningMsg, err := a.askLLM(job.GetContext(),
|
||||
append(c, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: reasoningPrompt,
|
||||
}),
|
||||
maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to get reasoning: %w", err)
|
||||
}
|
||||
|
||||
originalReasoning := reasoningMsg.Content
|
||||
|
||||
xlog.Debug("[pickAction] picking action", "messages", c)
|
||||
|
||||
actionsID := []string{"reply"}
|
||||
for _, m := range a.availableActions() {
|
||||
actionsID = append(actionsID, m.Definition().Name.String())
|
||||
}
|
||||
|
||||
xlog.Debug("[pickAction] actionsID", "actionsID", actionsID)
|
||||
|
||||
intentionsTools := action.NewIntention(actionsID...)
|
||||
// TODO: FORCE to select ana ction here
|
||||
// NOTE: we do not give the full conversation here to pick the action
|
||||
// to avoid hallucinations
|
||||
|
||||
// Extract an action
|
||||
params, err := a.decision(job,
|
||||
append(c, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: "Pick the relevant action given the following reasoning: " + originalReasoning,
|
||||
}),
|
||||
types.Actions{intentionsTools}.ToTools(),
|
||||
intentionsTools.Definition().Name.String(), maxRetries)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to get the action tool parameters: %v", err)
|
||||
}
|
||||
|
||||
if params.actionParams == nil {
|
||||
xlog.Debug("[pickAction] no action params found")
|
||||
return nil, nil, params.message, nil
|
||||
}
|
||||
|
||||
actionChoice := action.IntentResponse{}
|
||||
err = params.actionParams.Unmarshal(&actionChoice)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
if actionChoice.Tool == "" || actionChoice.Tool == "reply" {
|
||||
xlog.Debug("[pickAction] no action found, replying")
|
||||
return nil, nil, "", nil
|
||||
}
|
||||
|
||||
chosenAction := a.availableActions().Find(actionChoice.Tool)
|
||||
|
||||
xlog.Debug("[pickAction] chosenAction", "chosenAction", chosenAction, "actionName", actionChoice.Tool)
|
||||
|
||||
// // Let's double check if the action is correct by asking the LLM to judge it
|
||||
|
||||
// if chosenAction!= nil {
|
||||
// promptString:= "Given the following goal and thoughts, is the action correct? \n\n"
|
||||
// promptString+= fmt.Sprintf("Goal: %s\n", goalResponse.Goal)
|
||||
// promptString+= fmt.Sprintf("Thoughts: %s\n", originalReasoning)
|
||||
// promptString+= fmt.Sprintf("Action: %s\n", chosenAction.Definition().Name.String())
|
||||
// promptString+= fmt.Sprintf("Action description: %s\n", chosenAction.Definition().Description)
|
||||
// promptString+= fmt.Sprintf("Action parameters: %s\n", params.actionParams)
|
||||
|
||||
// }
|
||||
|
||||
return chosenAction, nil, originalReasoning, nil
|
||||
}
|
||||
|
||||
+606
-671
File diff suppressed because it is too large
Load Diff
+47
-18
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
@@ -37,7 +37,8 @@ var debugOptions = []types.JobOption{
|
||||
}
|
||||
|
||||
type TestAction struct {
|
||||
response map[string]string
|
||||
response map[string]string
|
||||
definition *types.ActionDefinition
|
||||
}
|
||||
|
||||
func (a *TestAction) Plannable() bool {
|
||||
@@ -55,7 +56,7 @@ func (a *TestAction) Run(c context.Context, sharedState *types.AgentSharedState,
|
||||
}
|
||||
|
||||
func (a *TestAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
def := types.ActionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "get current weather",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
@@ -71,6 +72,11 @@ func (a *TestAction) Definition() types.ActionDefinition {
|
||||
|
||||
Required: []string{"location"},
|
||||
}
|
||||
|
||||
if a.definition != nil {
|
||||
def = *a.definition
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
type FakeStoreResultAction struct {
|
||||
@@ -128,7 +134,6 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
EnableForceReasoning,
|
||||
WithTimeout("10m"),
|
||||
WithLoopDetectionSteps(3),
|
||||
// WithRandomIdentity(),
|
||||
WithActions(&TestAction{response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
@@ -153,6 +158,9 @@ var _ = Describe("Agent test", func() {
|
||||
}
|
||||
Expect(reasons).To(ContainElement(testActionResult), fmt.Sprint(res))
|
||||
Expect(reasons).To(ContainElement(testActionResult2), fmt.Sprint(res))
|
||||
|
||||
Expect(len(res.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(res.Conversation))
|
||||
|
||||
reasons = []string{}
|
||||
|
||||
res = agent.Ask(
|
||||
@@ -225,11 +233,33 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
WithLLMAPIKey(apiKeyURL),
|
||||
WithTimeout("10m"),
|
||||
WithMaxEvaluationLoops(1),
|
||||
WithActions(
|
||||
&TestAction{response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
"milan": testActionResult2,
|
||||
}},
|
||||
&TestAction{
|
||||
response: map[string]string{
|
||||
"boston": testActionResult,
|
||||
"milan": testActionResult2,
|
||||
},
|
||||
},
|
||||
&TestAction{
|
||||
response: map[string]string{
|
||||
"flight": "Flight options from Boston to Milan (April 22-26, 2025):\n• Outbound: Boston Logan (BOS) → Milan Malpensa (MXP), April 22, 2025\n - Economy: $450-650 (Alitalia, Delta, Lufthansa)\n - Business: $1,200-1,800\n - Flight time: 8h 15m (1 stop) or 9h 45m (direct)\n• Return: Milan Malpensa (MXP) → Boston Logan (BOS), April 26, 2025\n - Economy: $420-580\n - Business: $1,100-1,600\n• Total estimated cost: $870-1,230 per person\n• Best booking window: 2-3 months in advance for optimal prices",
|
||||
"hotel": "Hotel recommendations in Milan for April 22-26, 2025:\n• Luxury (4-5 stars): $200-400/night\n - Hotel Principe di Savoia: $380/night (central location)\n - Mandarin Oriental: $420/night (luxury amenities)\n• Mid-range (3-4 stars): $120-200/night\n - Hotel Spadari al Duomo: $160/night (near cathedral)\n - Hotel Milano Scala: $140/night (theater district)\n• Budget (2-3 stars): $80-120/night\n - Hotel Bernina: $95/night (near train station)\n• Total 4-night stay: $320-1,680 depending on category\n• Booking tip: Reserve early for spring season discounts",
|
||||
"car": "Car rental options in Milan for April 22-26, 2025:\n• Economy cars: $35-50/day (Fiat 500, VW Polo)\n• Compact cars: $45-65/day (Ford Focus, Opel Astra)\n• Mid-size cars: $60-85/day (BMW 3 Series, Audi A4)\n• SUV/Luxury: $90-150/day (BMW X3, Mercedes E-Class)\n• Total 4-day rental: $140-600\n• Pickup locations: Milan Malpensa Airport, Milan Central Station, city center\n• Insurance: $15-25/day additional\n• Fuel: ~$60-80 for 4 days of city driving\n• Parking: $20-40/day in city center hotels",
|
||||
"food": "Dining budget and recommendations for Milan (April 22-26, 2025):\n• Fine dining: $80-150/person (Michelin-starred restaurants)\n - Cracco: $120/person (2 Michelin stars)\n - Trussardi alla Scala: $100/person\n• Mid-range restaurants: $40-80/person\n - Luini: $15/person (famous panzerotti)\n - Piz: $25/person (authentic pizza)\n• Casual dining: $20-40/person\n - Aperitivo bars: $15-25/person\n - Street food: $8-15/person\n• Daily food budget: $60-120/person\n• Total 4-day food cost: $240-480/person\n• Must-try: Risotto alla Milanese, Osso Buco, Panettone",
|
||||
},
|
||||
definition: &types.ActionDefinition{
|
||||
Name: "search_internet",
|
||||
Description: "search the internet for information",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The query to search for",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
),
|
||||
EnablePlanning,
|
||||
EnableForceReasoning,
|
||||
@@ -241,21 +271,20 @@ var _ = Describe("Agent test", func() {
|
||||
defer agent.Stop()
|
||||
|
||||
result := agent.Ask(
|
||||
types.WithText("Use the plan tool to do two actions in sequence: search for the weather in boston and search for the weather in milan"),
|
||||
types.WithText("Create a plan for my 4-day trip from Boston to milan in April of this year (2025). I'm not sure about the dates yet, I want you to find out the best dates also according to what you find."),
|
||||
)
|
||||
Expect(len(result.State)).To(BeNumerically(">", 1))
|
||||
|
||||
Expect(len(result.Conversation)).To(BeNumerically(">", 1), fmt.Sprint(result.Conversation))
|
||||
|
||||
Expect(len(result.Plans)).To(BeNumerically(">=", 1), fmt.Sprintf("%+v", result))
|
||||
Expect(len(result.State)).To(BeNumerically(">=", 1))
|
||||
|
||||
actionsExecuted := []string{}
|
||||
actionResults := []string{}
|
||||
for _, r := range result.State {
|
||||
xlog.Info(r.Result)
|
||||
actionsExecuted = append(actionsExecuted, r.Action.Definition().Name.String())
|
||||
actionResults = append(actionResults, r.ActionResult.Result)
|
||||
}
|
||||
Expect(actionsExecuted).To(ContainElement("get_weather"), fmt.Sprint(result))
|
||||
Expect(actionsExecuted).To(ContainElement("plan"), fmt.Sprint(result))
|
||||
Expect(actionResults).To(ContainElement(testActionResult), fmt.Sprint(result))
|
||||
Expect(actionResults).To(ContainElement(testActionResult2), fmt.Sprint(result))
|
||||
Expect(actionsExecuted).To(Or(ContainElement("search_internet"), ContainElement("get_weather")), fmt.Sprint(result))
|
||||
})
|
||||
|
||||
It("Can initiate conversations", func() {
|
||||
@@ -267,9 +296,9 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
WithLLMAPIKey(apiKeyURL),
|
||||
WithTimeout("10m"),
|
||||
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
|
||||
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
|
||||
mu.Lock()
|
||||
message = m
|
||||
message = m.Message
|
||||
mu.Unlock()
|
||||
}),
|
||||
WithActions(
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
type EvaluationResult struct {
|
||||
Satisfied bool `json:"satisfied"`
|
||||
Gaps []string `json:"gaps"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
type GoalExtraction struct {
|
||||
Goal string `json:"goal"`
|
||||
Constraints []string `json:"constraints"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
func (a *Agent) extractGoal(job *types.Job, conv []openai.ChatCompletionMessage) (*GoalExtraction, error) {
|
||||
// Create the goal extraction schema
|
||||
schema := jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The main goal or request from the user",
|
||||
},
|
||||
"constraints": {
|
||||
Type: jsonschema.Array,
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
Description: "Any constraints or requirements specified by the user",
|
||||
},
|
||||
"context": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Additional context that might be relevant for understanding the goal",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal", "constraints", "context"},
|
||||
}
|
||||
|
||||
// Create the goal extraction prompt
|
||||
prompt := `Analyze the conversation and extract the user's main goal, any constraints, and relevant context.
|
||||
Consider the entire conversation history to understand the complete context and requirements.
|
||||
Focus on identifying the primary objective and any specific requirements or limitations mentioned.`
|
||||
|
||||
var result GoalExtraction
|
||||
err := llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
|
||||
append(
|
||||
[]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
conv...), a.options.LLMAPI.Model, schema, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting goal: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (a *Agent) evaluateJob(job *types.Job, conv []openai.ChatCompletionMessage) (*EvaluationResult, error) {
|
||||
if !a.options.enableEvaluation {
|
||||
return &EvaluationResult{Satisfied: true}, nil
|
||||
}
|
||||
|
||||
// Extract the goal first
|
||||
goal, err := a.extractGoal(job, conv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting goal: %w", err)
|
||||
}
|
||||
|
||||
// Create the evaluation schema
|
||||
schema := jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"satisfied": {
|
||||
Type: jsonschema.Boolean,
|
||||
},
|
||||
"gaps": {
|
||||
Type: jsonschema.Array,
|
||||
Items: &jsonschema.Definition{
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
},
|
||||
"reasoning": {
|
||||
Type: jsonschema.String,
|
||||
},
|
||||
},
|
||||
Required: []string{"satisfied", "gaps", "reasoning"},
|
||||
}
|
||||
|
||||
// Create the evaluation prompt
|
||||
prompt := fmt.Sprintf(`Evaluate if the assistant has satisfied the user's request. Consider:
|
||||
1. The identified goal: %s
|
||||
2. Constraints and requirements: %v
|
||||
3. Context: %s
|
||||
4. The conversation history
|
||||
5. Any gaps or missing information
|
||||
6. Whether the response fully addresses the user's needs
|
||||
|
||||
Provide a detailed evaluation with specific gaps if any are found.`,
|
||||
goal.Goal,
|
||||
goal.Constraints,
|
||||
goal.Context)
|
||||
|
||||
var result EvaluationResult
|
||||
err = llm.GenerateTypedJSONWithConversation(job.GetContext(), a.client,
|
||||
append(
|
||||
[]openai.ChatCompletionMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: prompt,
|
||||
},
|
||||
},
|
||||
conv...),
|
||||
a.options.LLMAPI.Model, schema, &result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error generating evaluation: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleEvaluation(job *types.Job, conv []openai.ChatCompletionMessage, currentLoop int) (bool, []openai.ChatCompletionMessage, error) {
|
||||
if !a.options.enableEvaluation || currentLoop >= a.options.maxEvaluationLoops {
|
||||
return true, conv, nil
|
||||
}
|
||||
|
||||
result, err := a.evaluateJob(job, conv)
|
||||
if err != nil {
|
||||
return false, conv, err
|
||||
}
|
||||
|
||||
if result.Satisfied {
|
||||
return true, conv, nil
|
||||
}
|
||||
|
||||
// If there are gaps, we need to address them
|
||||
if len(result.Gaps) > 0 {
|
||||
// Add the evaluation result to the conversation
|
||||
conv = append(conv, openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Evaluation found gaps that need to be addressed:\n%s\nReasoning: %s",
|
||||
result.Gaps, result.Reasoning),
|
||||
})
|
||||
|
||||
xlog.Debug("Evaluation found gaps, incrementing loop count", "loop", currentLoop+1)
|
||||
return false, conv, nil
|
||||
}
|
||||
|
||||
return true, conv, nil
|
||||
}
|
||||
+169
-13
@@ -1,23 +1,35 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
|
||||
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
|
||||
len(conv) <= 0 {
|
||||
// Only run KB recall/lookup when KB is explicitly enabled; long-term/summary memory
|
||||
// only affect saving in saveConversation, not this lookup.
|
||||
if !a.options.enableKB || len(conv) <= 0 {
|
||||
xlog.Debug("[Knowledge Base Lookup] Disabled, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
|
||||
if !a.options.kbAutoSearch {
|
||||
xlog.Debug("[Knowledge Base Lookup] Auto search disabled, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
if a.options.ragdb == nil {
|
||||
xlog.Debug("[Knowledge Base Lookup] No RAG DB configured, skipping", "agent", a.Character.Name)
|
||||
return conv
|
||||
}
|
||||
|
||||
var obs *types.Observable
|
||||
if job != nil && job.Obs != nil && a.observer != nil {
|
||||
obs = a.observer.NewObservable()
|
||||
@@ -90,7 +102,7 @@ func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
|
||||
|
||||
if obs != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
Conversation: []openai.ChatCompletionMessage{systemMessage},
|
||||
Conversation: []openai.ChatCompletionMessage{systemMessage},
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
@@ -125,24 +137,168 @@ func (a *Agent) saveCurrentConversation(conv Messages) {
|
||||
xlog.Info("Saving conversation", "agent", a.Character.Name, "conversation size", len(conv))
|
||||
|
||||
if a.options.enableSummaryMemory && len(conv) > 0 {
|
||||
msg, err := a.askLLM(a.context.Context, []openai.ChatCompletionMessage{{
|
||||
Role: "user",
|
||||
Content: "Summarize the conversation below, keep the highlights as a bullet list:\n" + Messages(conv).String(),
|
||||
}}, maxRetries)
|
||||
fragment := cogito.NewEmptyFragment().AddStartMessage("user", "Summarize the conversation below, keep the highlights as a bullet list:\n"+Messages(conv).String())
|
||||
fragment, err := a.llm.Ask(a.context.Context, fragment)
|
||||
if err != nil {
|
||||
xlog.Error("Error summarizing conversation", "error", err)
|
||||
}
|
||||
msg := fragment.LastMessage()
|
||||
|
||||
if err := a.options.ragdb.Store(msg.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
}
|
||||
} else {
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
// Use the conversation storage mode to determine what to store
|
||||
switch a.options.conversationStorageMode {
|
||||
case StoreWholeConversation:
|
||||
// Store the entire conversation as a single block
|
||||
if len(conv) > 0 {
|
||||
convStr := Messages(conv).String()
|
||||
if err := a.options.ragdb.Store(convStr); err != nil {
|
||||
xlog.Error("Error storing whole conversation into memory", "error", err)
|
||||
}
|
||||
}
|
||||
case StoreUserAndAssistant:
|
||||
// Store user and assistant messages separately
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" || message.Role == "assistant" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing message into memory", "error", err, "role", message.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
case StoreUserOnly:
|
||||
fallthrough
|
||||
default:
|
||||
// Store only user messages (default behavior)
|
||||
for _, message := range conv {
|
||||
if message.Role == "user" {
|
||||
if err := a.options.ragdb.Store(message.Content); err != nil {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KBWrapperActions wraps RAGDB functionality as actions
|
||||
type KBWrapperActions struct {
|
||||
ragdb RAGDB
|
||||
kbResults int
|
||||
}
|
||||
|
||||
type SearchKnowledgeBaseAction struct {
|
||||
*KBWrapperActions
|
||||
}
|
||||
|
||||
type AddToKnowledgeBaseAction struct {
|
||||
*KBWrapperActions
|
||||
}
|
||||
|
||||
// NewKBWrapperActions creates factory functions for KB wrapper actions
|
||||
func NewKBWrapperActions(ragdb RAGDB, kbResults int) (*SearchKnowledgeBaseAction, *AddToKnowledgeBaseAction) {
|
||||
wrapper := &KBWrapperActions{
|
||||
ragdb: ragdb,
|
||||
kbResults: kbResults,
|
||||
}
|
||||
return &SearchKnowledgeBaseAction{wrapper}, &AddToKnowledgeBaseAction{wrapper}
|
||||
}
|
||||
|
||||
func (a *SearchKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if a.ragdb == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
if req.Query == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
|
||||
}
|
||||
|
||||
results, err := a.ragdb.Search(req.Query, a.kbResults)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search knowledge base: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("No results found for query: %q", req.Query),
|
||||
}, nil
|
||||
}
|
||||
|
||||
formatResults := ""
|
||||
for i, r := range results {
|
||||
formatResults += fmt.Sprintf("%d. %s\n", i+1, r)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Found %d result(s) for query %q:\n%s", len(results), req.Query, formatResults),
|
||||
Metadata: map[string]interface{}{
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SearchKnowledgeBaseAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName("search_memory"),
|
||||
Description: "Search your memory for relevant information using a query string",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The search query to find relevant information in the knowledge base",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AddToKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if a.ragdb == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
|
||||
if req.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("content cannot be empty")
|
||||
}
|
||||
|
||||
if err := a.ragdb.Store(req.Content); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to store content in knowledge base: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: "Successfully added content to knowledge base",
|
||||
Metadata: map[string]interface{}{
|
||||
"content": req.Content,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AddToKnowledgeBaseAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName("add_memory"),
|
||||
Description: "Add new content to your memory for future retrieval",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The content to store in the knowledge base",
|
||||
},
|
||||
},
|
||||
Required: []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
+118
-120
@@ -3,18 +3,21 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
var _ types.Action = &mcpAction{}
|
||||
var _ types.Action = &mcpWrapperAction{}
|
||||
|
||||
type MCPServer struct {
|
||||
URL string `json:"url"`
|
||||
@@ -22,49 +25,26 @@ type MCPServer struct {
|
||||
}
|
||||
|
||||
type MCPSTDIOServer struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
Cmd string `json:"cmd"`
|
||||
}
|
||||
|
||||
type mcpAction struct {
|
||||
mcpClient *mcp.Client
|
||||
type mcpWrapperAction struct {
|
||||
mcpClient *mcp.ClientSession
|
||||
inputSchema ToolInputSchema
|
||||
toolName string
|
||||
toolDescription string
|
||||
}
|
||||
|
||||
func (a *mcpAction) Plannable() bool {
|
||||
return true
|
||||
func (m *mcpWrapperAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
// We don't call the method here, it is used by cogito.
|
||||
// We will just use these to have a list of actions that MCP server provides for resolving internal states
|
||||
return types.ActionResult{Result: "MCP action called"}, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to call tool", "error", err.Error())
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: textResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mcpAction) Definition() types.ActionDefinition {
|
||||
func (m *mcpWrapperAction) Definition() types.ActionDefinition {
|
||||
props := map[string]jsonschema.Definition{}
|
||||
dat, err := json.Marshal(m.inputSchema.Properties)
|
||||
if err != nil {
|
||||
@@ -87,87 +67,110 @@ 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, &mcpWrapperAction{
|
||||
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.closeMCPServers() // Make sure we stop all previous servers if any is active
|
||||
|
||||
a.mcpActions = nil
|
||||
a.mcpActionDefinitions = 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),
|
||||
}
|
||||
|
||||
// Create a new client
|
||||
client := mcp.NewClient(transport)
|
||||
streamableTransport := &mcp.StreamableClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
session, err := client.Connect(a.context, streamableTransport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server via StreamableClientTransport", "server", mcpServer, "error", err.Error())
|
||||
|
||||
sseTransport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
session, err = client.Connect(a.context, sseTransport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server via SSEClientTransport", "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())
|
||||
}
|
||||
@@ -175,51 +178,46 @@ func (a *Agent) initMCPActions() error {
|
||||
}
|
||||
|
||||
// MCP STDIO Servers
|
||||
|
||||
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())
|
||||
}
|
||||
generatedActions = append(generatedActions, actions...)
|
||||
}
|
||||
|
||||
a.mcpActions = generatedActions
|
||||
a.mcpActionDefinitions = generatedActions
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) closeMCPSTDIOServers() {
|
||||
client := stdio.NewClient(a.options.mcpBoxURL)
|
||||
client.StopGroup(a.Character.Name)
|
||||
func (a *Agent) closeMCPServers() {
|
||||
for _, s := range a.mcpSessions {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
+14
-5
@@ -7,13 +7,14 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+119
-31
@@ -6,16 +6,31 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type Option func(*options) error
|
||||
|
||||
// ConversationStorageMode defines how conversations are stored in the knowledge base
|
||||
type ConversationStorageMode string
|
||||
|
||||
const (
|
||||
// StoreUserOnly stores only user messages (default)
|
||||
StoreUserOnly ConversationStorageMode = "user_only"
|
||||
// StoreUserAndAssistant stores both user and assistant messages separately
|
||||
StoreUserAndAssistant ConversationStorageMode = "user_and_assistant"
|
||||
// StoreWholeConversation stores the entire conversation as a single block
|
||||
StoreWholeConversation ConversationStorageMode = "whole_conversation"
|
||||
)
|
||||
|
||||
type llmOptions struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
MultimodalModel string
|
||||
ReviewerModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
}
|
||||
|
||||
type options struct {
|
||||
@@ -27,18 +42,23 @@ type options struct {
|
||||
jobFilters types.JobFilters
|
||||
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
|
||||
stripThinkingTags bool
|
||||
kbAutoSearch bool
|
||||
conversationStorageMode ConversationStorageMode
|
||||
|
||||
canStopItself bool
|
||||
initiateConversations bool
|
||||
loopDetectionSteps int
|
||||
forceReasoning bool
|
||||
enableGuidedTools bool
|
||||
canPlan bool
|
||||
disableSinkState bool
|
||||
characterfile string
|
||||
statefile string
|
||||
schedulerStorePath string // Path to scheduler JSON storage file
|
||||
context context.Context
|
||||
permanentGoal string
|
||||
timeout string
|
||||
periodicRuns time.Duration
|
||||
schedulerPollInterval time.Duration
|
||||
kbResults int
|
||||
ragdb RAGDB
|
||||
|
||||
@@ -58,9 +78,8 @@ type options struct {
|
||||
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpBoxURL string
|
||||
mcpPrepareScript string
|
||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||
newConversationsSubscribers []func(*types.ConversationMessage)
|
||||
|
||||
observer Observer
|
||||
parallelJobs int
|
||||
@@ -74,14 +93,19 @@ func (o *options) SeparatedMultimodalModel() bool {
|
||||
|
||||
func defaultOptions() *options {
|
||||
return &options{
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
loopDetectionSteps: 10,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
schedulerPollInterval: 30 * time.Second,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
kbAutoSearch: true, // Default to true to maintain backward compatibility
|
||||
conversationStorageMode: StoreUserOnly, // Default to user-only for backward compatibility
|
||||
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: "",
|
||||
@@ -113,6 +137,11 @@ var EnableForceReasoning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableGuidedTools = func(o *options) error {
|
||||
o.enableGuidedTools = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableKnowledgeBase = func(o *options) error {
|
||||
o.enableKB = true
|
||||
o.kbResults = 5
|
||||
@@ -131,13 +160,6 @@ func WithTimeout(timeout string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoopDetectionSteps(steps int) Option {
|
||||
return func(o *options) error {
|
||||
o.loopDetectionSteps = steps
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithConversationsPath(path string) Option {
|
||||
return func(o *options) error {
|
||||
o.conversationsPath = path
|
||||
@@ -171,7 +193,7 @@ func WithParallelJobs(jobs int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
||||
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
|
||||
return func(o *options) error {
|
||||
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
||||
return nil
|
||||
@@ -188,6 +210,18 @@ var EnablePlanning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var DisableSinkState = func(o *options) error {
|
||||
o.disableSinkState = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var WithPlanReviewerLLM = func(model string) Option {
|
||||
return func(o *options) error {
|
||||
o.LLMAPI.ReviewerModel = model
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// EnableStandaloneJob is an option to enable the agent
|
||||
// to run jobs in the background automatically
|
||||
var EnableStandaloneJob = func(o *options) error {
|
||||
@@ -217,6 +251,19 @@ func WithRAGDB(db RAGDB) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithConversationStorageMode sets how conversations are stored in the knowledge base
|
||||
func WithConversationStorageMode(mode ConversationStorageMode) Option {
|
||||
return func(o *options) error {
|
||||
switch mode {
|
||||
case StoreUserOnly, StoreUserAndAssistant, StoreWholeConversation:
|
||||
o.conversationStorageMode = mode
|
||||
default:
|
||||
o.conversationStorageMode = StoreUserOnly
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithSystemPrompt(prompt string) Option {
|
||||
return func(o *options) error {
|
||||
o.systemPrompt = prompt
|
||||
@@ -238,13 +285,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
|
||||
@@ -331,6 +371,18 @@ func WithPeriodicRuns(duration string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithSchedulerPollInterval(duration string) Option {
|
||||
return func(o *options) error {
|
||||
t, err := time.ParseDuration(duration)
|
||||
if err != nil {
|
||||
o.schedulerPollInterval = 30 * time.Second
|
||||
return nil
|
||||
}
|
||||
o.schedulerPollInterval = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(o *options) error {
|
||||
o.context = ctx
|
||||
@@ -388,7 +440,7 @@ func WithRandomIdentity(guidance ...string) Option {
|
||||
|
||||
func WithActions(actions ...types.Action) Option {
|
||||
return func(o *options) error {
|
||||
o.userActions = actions
|
||||
o.userActions = append(o.userActions, actions...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -425,3 +477,39 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
func WithKBAutoSearch(enabled bool) Option {
|
||||
return func(o *options) error {
|
||||
o.kbAutoSearch = enabled
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSchedulerStorePath sets the path for the scheduler's JSON storage file
|
||||
func WithSchedulerStorePath(path string) Option {
|
||||
return func(o *options) error {
|
||||
o.schedulerStorePath = path
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
// agentSchedulerExecutor implements scheduler.AgentExecutor for executing scheduled tasks through the agent
|
||||
type agentSchedulerExecutor struct {
|
||||
agent *Agent
|
||||
}
|
||||
|
||||
// Execute processes a scheduled task by creating a job for the agent
|
||||
func (e *agentSchedulerExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
|
||||
// Create a job for the reminder
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)),
|
||||
types.WithReasoningCallback(e.agent.options.reasoningCallback),
|
||||
types.WithResultCallback(e.agent.options.resultCallback),
|
||||
types.WithContext(ctx),
|
||||
types.WithMetadata(map[string]any{
|
||||
"message": prompt,
|
||||
"is_reminder": true,
|
||||
"type": "scheduled",
|
||||
}),
|
||||
)
|
||||
|
||||
// Attach observable so UI can show reminder processing state
|
||||
if e.agent.observer != nil {
|
||||
obs := e.agent.observer.NewObservable()
|
||||
obs.Name = "reminder"
|
||||
obs.Icon = "bell"
|
||||
e.agent.observer.Update(*obs)
|
||||
reminderJob.Obs = obs
|
||||
}
|
||||
|
||||
// Send the job to be processed
|
||||
e.agent.jobQueue <- reminderJob
|
||||
|
||||
// Wait for the job to complete or context to be cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
result, err := reminderJob.Result.WaitResult(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Error != nil {
|
||||
return &scheduler.JobResult{
|
||||
Response: "",
|
||||
Error: result.Error,
|
||||
}, result.Error
|
||||
}
|
||||
return &scheduler.JobResult{
|
||||
Response: result.Response,
|
||||
Error: nil,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
+16
-42
@@ -2,18 +2,32 @@ package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
func templateBase(templateName, templatetext string) (*template.Template, error) {
|
||||
return template.New(templateName).Funcs(sprig.FuncMap()).Parse(templatetext)
|
||||
}
|
||||
|
||||
func templateExecute(template *template.Template, data interface{}) (string, error) {
|
||||
prompt := bytes.NewBuffer([]byte{})
|
||||
err := template.Execute(prompt, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prompt.String(), nil
|
||||
}
|
||||
|
||||
func renderTemplate(templ string, hud *PromptHUD, actions types.Actions, reasoning string) (string, error) {
|
||||
// prepare the prompt
|
||||
prompt := bytes.NewBuffer([]byte{})
|
||||
|
||||
promptTemplate, err := template.New("pickAction").Parse(templ)
|
||||
promptTemplate, err := templateBase("pickAction", templ)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -94,7 +108,6 @@ Guidelines:
|
||||
4. Update your state appropriately
|
||||
|
||||
When making decisions:
|
||||
- Use the "reply" tool to provide final responses
|
||||
- Update your state using appropriate tools
|
||||
- Plan complex tasks using the planning tool
|
||||
- Consider both immediate and long-term goals
|
||||
@@ -105,43 +118,4 @@ Remember:
|
||||
- Keep track of your progress and state
|
||||
- Be proactive in addressing potential issues
|
||||
|
||||
Available Tools:
|
||||
{{range .Actions -}}
|
||||
- {{.Name}}: {{.Description }}
|
||||
{{ end }}
|
||||
|
||||
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}
|
||||
` + hudTemplate
|
||||
|
||||
const reSelfEvalTemplate = pickSelfTemplate
|
||||
|
||||
const pickActionTemplate = hudTemplate + `
|
||||
Your only task is to analyze the conversation and determine a goal and the best tool to use, or just a final response if we have fullfilled the goal.
|
||||
|
||||
Guidelines:
|
||||
1. Review the current state, what was done already and context
|
||||
2. Consider available tools and their purposes
|
||||
3. Plan your approach carefully
|
||||
4. Explain your reasoning clearly
|
||||
|
||||
When choosing actions:
|
||||
- Use "reply" or "answer" tools for direct responses
|
||||
- Select appropriate tools for specific tasks
|
||||
- Consider the impact of each action
|
||||
- Plan for potential challenges
|
||||
|
||||
Decision Process:
|
||||
1. Analyze the situation
|
||||
2. Consider available options
|
||||
3. Choose the best course of action
|
||||
4. Explain your reasoning
|
||||
5. Execute the chosen action
|
||||
|
||||
Available Tools:
|
||||
{{range .Actions -}}
|
||||
- {{.Name}}: {{.Description }}
|
||||
{{ end }}
|
||||
|
||||
{{if .Reasoning}}Previous Reasoning: {{.Reasoning}}{{end}}`
|
||||
|
||||
const reEvalTemplate = pickActionTemplate
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// TaskStore defines the interface for task persistence
|
||||
type TaskStore interface {
|
||||
// Create adds a new task
|
||||
Create(task *Task) error
|
||||
|
||||
// Get retrieves a task by ID
|
||||
Get(id string) (*Task, error)
|
||||
|
||||
// GetAll retrieves all tasks
|
||||
GetAll() ([]*Task, error)
|
||||
|
||||
// GetDue retrieves tasks that are due for execution
|
||||
GetDue() ([]*Task, error)
|
||||
|
||||
// GetByAgent retrieves all tasks for a specific agent
|
||||
GetByAgent(agentName string) ([]*Task, error)
|
||||
|
||||
// Update updates an existing task
|
||||
Update(task *Task) error
|
||||
|
||||
// Delete removes a task
|
||||
Delete(id string) error
|
||||
|
||||
// LogRun records a task execution
|
||||
LogRun(run *TaskRun) error
|
||||
|
||||
// GetRuns retrieves execution history for a task
|
||||
GetRuns(taskID string, limit int) ([]*TaskRun, error)
|
||||
|
||||
// Close releases resources
|
||||
Close() error
|
||||
}
|
||||
|
||||
// AgentExecutor defines the interface for executing agent tasks
|
||||
type AgentExecutor interface {
|
||||
Execute(ctx context.Context, agentName string, prompt string) (*JobResult, error)
|
||||
}
|
||||
|
||||
// JobResult represents the result of an agent execution
|
||||
type JobResult struct {
|
||||
Response string
|
||||
Error error
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONStore implements TaskStore using JSON file storage
|
||||
type JSONStore struct {
|
||||
filePath string
|
||||
mu sync.RWMutex
|
||||
data *storeData
|
||||
}
|
||||
|
||||
type storeData struct {
|
||||
Tasks []*Task `json:"tasks"`
|
||||
TaskRuns []*TaskRun `json:"task_runs"`
|
||||
}
|
||||
|
||||
// NewJSONStore creates a new JSON-based task store
|
||||
func NewJSONStore(filePath string) (*JSONStore, error) {
|
||||
store := &JSONStore{
|
||||
filePath: filePath,
|
||||
data: &storeData{
|
||||
Tasks: make([]*Task, 0),
|
||||
TaskRuns: make([]*TaskRun, 0),
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.load(); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to load store: %w", err)
|
||||
}
|
||||
// File doesn't exist, create it
|
||||
if err := store.save(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create store file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Create adds a new task
|
||||
func (s *JSONStore) Create(task *Task) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check for duplicate ID
|
||||
for _, t := range s.data.Tasks {
|
||||
if t.ID == task.ID {
|
||||
return fmt.Errorf("task with ID %s already exists", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
s.data.Tasks = append(s.data.Tasks, task)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// Get retrieves a task by ID
|
||||
func (s *JSONStore) Get(id string) (*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.ID == id {
|
||||
return task, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
|
||||
// GetAll retrieves all tasks
|
||||
func (s *JSONStore) GetAll() ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
tasks := make([]*Task, len(s.data.Tasks))
|
||||
copy(tasks, s.data.Tasks)
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetDue retrieves tasks that are due for execution
|
||||
func (s *JSONStore) GetDue() ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
now := time.Now()
|
||||
dueTasks := make([]*Task, 0)
|
||||
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.Status == TaskStatusActive && now.After(task.NextRun) {
|
||||
dueTasks = append(dueTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return dueTasks, nil
|
||||
}
|
||||
|
||||
// GetByAgent retrieves all tasks for a specific agent
|
||||
func (s *JSONStore) GetByAgent(agentName string) ([]*Task, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
agentTasks := make([]*Task, 0)
|
||||
for _, task := range s.data.Tasks {
|
||||
if task.AgentName == agentName {
|
||||
agentTasks = append(agentTasks, task)
|
||||
}
|
||||
}
|
||||
|
||||
return agentTasks, nil
|
||||
}
|
||||
|
||||
// Update updates an existing task
|
||||
func (s *JSONStore) Update(task *Task) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, t := range s.data.Tasks {
|
||||
if t.ID == task.ID {
|
||||
task.UpdatedAt = time.Now()
|
||||
s.data.Tasks[i] = task
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("task not found: %s", task.ID)
|
||||
}
|
||||
|
||||
// Delete removes a task
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for i, task := range s.data.Tasks {
|
||||
if task.ID == id {
|
||||
// Remove task from slice
|
||||
s.data.Tasks = append(s.data.Tasks[:i], s.data.Tasks[i+1:]...)
|
||||
return s.save()
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
|
||||
// LogRun records a task execution
|
||||
func (s *JSONStore) LogRun(run *TaskRun) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.data.TaskRuns = append(s.data.TaskRuns, run)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// GetRuns retrieves execution history for a task
|
||||
func (s *JSONStore) GetRuns(taskID string, limit int) ([]*TaskRun, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
runs := make([]*TaskRun, 0)
|
||||
for i := len(s.data.TaskRuns) - 1; i >= 0 && len(runs) < limit; i-- {
|
||||
if s.data.TaskRuns[i].TaskID == taskID {
|
||||
runs = append(runs, s.data.TaskRuns[i])
|
||||
}
|
||||
}
|
||||
|
||||
return runs, nil
|
||||
}
|
||||
|
||||
// Close releases resources (no-op for JSON store)
|
||||
func (s *JSONStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// load reads data from the JSON file
|
||||
func (s *JSONStore) load() error {
|
||||
file, err := os.ReadFile(s.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle empty file
|
||||
if len(file) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(file, s.data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure slices are not nil after unmarshaling
|
||||
if s.data.Tasks == nil {
|
||||
s.data.Tasks = make([]*Task, 0)
|
||||
}
|
||||
if s.data.TaskRuns == nil {
|
||||
s.data.TaskRuns = make([]*TaskRun, 0)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes data to the JSON file
|
||||
func (s *JSONStore) save() error {
|
||||
data, err := json.MarshalIndent(s.data, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal data: %w", err)
|
||||
}
|
||||
|
||||
basePath := filepath.Dir(s.filePath)
|
||||
os.MkdirAll(basePath, 0755)
|
||||
|
||||
return os.WriteFile(s.filePath, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// Scheduler manages scheduled tasks
|
||||
type Scheduler struct {
|
||||
store TaskStore
|
||||
executor AgentExecutor
|
||||
pollInterval time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
runningTasks map[string]context.CancelFunc
|
||||
}
|
||||
|
||||
// NewScheduler creates a new scheduler with the given store and executor
|
||||
func NewScheduler(store TaskStore, executor AgentExecutor, pollInterval time.Duration) *Scheduler {
|
||||
|
||||
return &Scheduler{
|
||||
store: store,
|
||||
executor: executor,
|
||||
pollInterval: pollInterval,
|
||||
runningTasks: make(map[string]context.CancelFunc),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the scheduler's polling loop
|
||||
func (s *Scheduler) Start() {
|
||||
if s.ctx != nil {
|
||||
xlog.Warn("Scheduler already started")
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
s.wg.Add(1)
|
||||
go s.run()
|
||||
xlog.Info("Task scheduler started", "poll_interval", s.pollInterval)
|
||||
}
|
||||
|
||||
// Stop gracefully stops the scheduler
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.wg.Wait()
|
||||
s.store.Close()
|
||||
xlog.Info("Task scheduler stopped")
|
||||
s.cancel = nil
|
||||
s.ctx = nil
|
||||
}
|
||||
|
||||
// run is the main polling loop
|
||||
func (s *Scheduler) run() {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.processDueTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processDueTasks checks for and executes due tasks
|
||||
func (s *Scheduler) processDueTasks() {
|
||||
tasks, err := s.store.GetDue()
|
||||
if err != nil {
|
||||
xlog.Error("Failed to get due tasks", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
xlog.Debug("Processing due tasks", "count", len(tasks))
|
||||
}
|
||||
|
||||
for _, task := range tasks {
|
||||
// Check if task is already running
|
||||
s.mu.RLock()
|
||||
_, running := s.runningTasks[task.ID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if running {
|
||||
xlog.Warn("Task already running, skipping", "task_id", task.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Execute task in goroutine
|
||||
s.wg.Add(1)
|
||||
go s.executeTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
// executeTask runs a single task
|
||||
func (s *Scheduler) executeTask(task *Task) {
|
||||
defer s.wg.Done()
|
||||
|
||||
taskCtx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
// Register running task
|
||||
s.mu.Lock()
|
||||
s.runningTasks[task.ID] = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.runningTasks, task.ID)
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
xlog.Info("Executing task", "task_id", task.ID, "agent", task.AgentName, "prompt", task.Prompt)
|
||||
|
||||
startTime := time.Now()
|
||||
run := NewTaskRun(task.ID)
|
||||
|
||||
// Execute the task
|
||||
result, err := s.executor.Execute(taskCtx, task.AgentName, task.Prompt)
|
||||
|
||||
run.DurationMs = time.Since(startTime).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
xlog.Error("Task execution failed", "task_id", task.ID, "error", err)
|
||||
} else {
|
||||
run.Status = "success"
|
||||
if result != nil {
|
||||
run.Result = result.Response
|
||||
}
|
||||
xlog.Info("Task executed successfully", "task_id", task.ID, "duration_ms", run.DurationMs)
|
||||
}
|
||||
|
||||
// Log the run
|
||||
if err := s.store.LogRun(run); err != nil {
|
||||
xlog.Error("Failed to log task run", "task_id", task.ID, "error", err)
|
||||
}
|
||||
|
||||
// Update task for next run
|
||||
now := time.Now()
|
||||
task.LastRun = &now
|
||||
|
||||
// For one-time tasks, mark as deleted
|
||||
if task.ScheduleType == ScheduleTypeOnce {
|
||||
if err := s.store.Delete(task.ID); err != nil {
|
||||
xlog.Error("Failed to delete task", "task_id", task.ID, "error", err)
|
||||
}
|
||||
} else {
|
||||
// Calculate next run
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
xlog.Error("Failed to calculate next run", "task_id", task.ID, "error", err)
|
||||
task.Status = TaskStatusPaused
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.Update(task); err != nil {
|
||||
xlog.Error("Failed to update task", "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD operations
|
||||
|
||||
// CreateTask adds a new task
|
||||
func (s *Scheduler) CreateTask(task *Task) error {
|
||||
return s.store.Create(task)
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by ID
|
||||
func (s *Scheduler) GetTask(id string) (*Task, error) {
|
||||
return s.store.Get(id)
|
||||
}
|
||||
|
||||
// GetAllTasks retrieves all tasks
|
||||
func (s *Scheduler) GetAllTasks() ([]*Task, error) {
|
||||
return s.store.GetAll()
|
||||
}
|
||||
|
||||
// GetTasksByAgent retrieves all tasks for a specific agent
|
||||
func (s *Scheduler) GetTasksByAgent(agentName string) ([]*Task, error) {
|
||||
return s.store.GetByAgent(agentName)
|
||||
}
|
||||
|
||||
// UpdateTask updates an existing task
|
||||
func (s *Scheduler) UpdateTask(task *Task) error {
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// DeleteTask removes a task
|
||||
func (s *Scheduler) DeleteTask(id string) error {
|
||||
return s.store.Delete(id)
|
||||
}
|
||||
|
||||
// GetTaskRuns retrieves execution history for a task
|
||||
func (s *Scheduler) GetTaskRuns(taskID string, limit int) ([]*TaskRun, error) {
|
||||
return s.store.GetRuns(taskID, limit)
|
||||
}
|
||||
|
||||
// PauseTask pauses a task
|
||||
func (s *Scheduler) PauseTask(id string) error {
|
||||
task, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = TaskStatusPaused
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// ResumeTask resumes a paused task
|
||||
func (s *Scheduler) ResumeTask(id string) error {
|
||||
task, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task.Status = TaskStatusActive
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.store.Update(task)
|
||||
}
|
||||
|
||||
// CancelRunningTask cancels a currently running task
|
||||
func (s *Scheduler) CancelRunningTask(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cancel, exists := s.runningTasks[id]
|
||||
if !exists {
|
||||
return fmt.Errorf("task not running: %s", id)
|
||||
}
|
||||
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestScheduler(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Scheduler Suite")
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// MockExecutor for testing
|
||||
type MockExecutor struct {
|
||||
executedTasks []string
|
||||
shouldError bool
|
||||
}
|
||||
|
||||
func (m *MockExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
|
||||
m.executedTasks = append(m.executedTasks, agentName+":"+prompt)
|
||||
if m.shouldError {
|
||||
return nil, errors.New("mock execution error")
|
||||
}
|
||||
return &scheduler.JobResult{Response: "test response"}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("Scheduler", func() {
|
||||
var (
|
||||
tempFile string
|
||||
store scheduler.TaskStore
|
||||
executor *MockExecutor
|
||||
sched *scheduler.Scheduler
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
// Create temporary file for JSON store
|
||||
f, err := os.CreateTemp("", "scheduler_test_*.json")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
tempFile = f.Name()
|
||||
f.Close()
|
||||
|
||||
store, err = scheduler.NewJSONStore(tempFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
executor = &MockExecutor{}
|
||||
sched = scheduler.NewScheduler(store, executor, 100*time.Millisecond)
|
||||
sched.Start()
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if sched != nil {
|
||||
sched.Stop()
|
||||
}
|
||||
os.Remove(tempFile)
|
||||
})
|
||||
|
||||
Describe("Task Creation", func() {
|
||||
It("should create a valid task with cron schedule", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.ID).NotTo(BeEmpty())
|
||||
Expect(task.AgentName).To(Equal("test-agent"))
|
||||
Expect(task.Prompt).To(Equal("test prompt"))
|
||||
Expect(task.ScheduleType).To(Equal(scheduler.ScheduleTypeCron))
|
||||
Expect(task.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
Expect(task.NextRun).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("should return error for invalid cron expression", func() {
|
||||
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "invalid cron")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should create a valid task with interval schedule", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeInterval, "3600000")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using duration", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "24h")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using day syntax", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "1d")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should create a valid task with once schedule using combined day+time", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "2d12h30m")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(2*24*time.Hour+12*time.Hour+30*time.Minute), 5*time.Second))
|
||||
})
|
||||
|
||||
It("should return error for invalid once duration", func() {
|
||||
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "invalid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Task IsDue", func() {
|
||||
It("should return true for active task past due time", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusActive,
|
||||
NextRun: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should return false for active task not yet due", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusActive,
|
||||
NextRun: time.Now().Add(1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should return false for paused task even if past due", func() {
|
||||
task := &scheduler.Task{
|
||||
Status: scheduler.TaskStatusPaused,
|
||||
NextRun: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
Expect(task.IsDue()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("JSON Store", func() {
|
||||
Context("CRUD operations", func() {
|
||||
It("should create and retrieve a task", func() {
|
||||
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
err = store.Create(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
retrieved, err := store.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(retrieved.ID).To(Equal(task.ID))
|
||||
Expect(retrieved.AgentName).To(Equal(task.AgentName))
|
||||
Expect(retrieved.Prompt).To(Equal(task.Prompt))
|
||||
})
|
||||
|
||||
It("should update a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
task.Prompt = "updated prompt"
|
||||
err := store.Update(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
updated, err := store.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updated.Prompt).To(Equal("updated prompt"))
|
||||
})
|
||||
|
||||
It("should delete a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
err := store.Delete(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = store.Get(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return error when getting non-existent task", func() {
|
||||
_, err := store.Get("non-existent-id")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Querying tasks", func() {
|
||||
BeforeEach(func() {
|
||||
// Create test tasks
|
||||
// task1: once schedule with 0s delay => immediately due
|
||||
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, "0s")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task1.NextRun = time.Now().Add(-1 * time.Hour) // force into the past
|
||||
// task2: cron schedule => next run in the future, not due
|
||||
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 1 1 *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// task3: once schedule but paused => not due
|
||||
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, "0s")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task3.Status = scheduler.TaskStatusPaused
|
||||
|
||||
Expect(store.Create(task1)).To(Succeed())
|
||||
Expect(store.Create(task2)).To(Succeed())
|
||||
Expect(store.Create(task3)).To(Succeed())
|
||||
})
|
||||
|
||||
It("should get all tasks", func() {
|
||||
tasks, err := store.GetAll()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tasks).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("should get only due tasks", func() {
|
||||
dueTasks, err := store.GetDue()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(dueTasks).To(HaveLen(1))
|
||||
Expect(dueTasks[0].AgentName).To(Equal("agent1"))
|
||||
Expect(dueTasks[0].Prompt).To(Equal("prompt1"))
|
||||
})
|
||||
|
||||
It("should get tasks by agent", func() {
|
||||
agentTasks, err := store.GetByAgent("agent1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(agentTasks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Task runs", func() {
|
||||
It("should log and retrieve task runs", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
run := scheduler.NewTaskRun(task.ID)
|
||||
run.Status = "success"
|
||||
run.Result = "test result"
|
||||
run.DurationMs = 1000
|
||||
|
||||
err := store.LogRun(run)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
runs, err := store.GetRuns(task.ID, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(1))
|
||||
Expect(runs[0].Status).To(Equal("success"))
|
||||
Expect(runs[0].Result).To(Equal("test result"))
|
||||
})
|
||||
|
||||
It("should limit returned runs", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
|
||||
// Create 5 runs
|
||||
for i := 0; i < 5; i++ {
|
||||
run := scheduler.NewTaskRun(task.ID)
|
||||
store.LogRun(run)
|
||||
}
|
||||
|
||||
runs, err := store.GetRuns(task.ID, 3)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(3))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Persistence", func() {
|
||||
It("should persist data across store instances", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
store.Create(task)
|
||||
store.Close()
|
||||
|
||||
// Create new store instance with same file
|
||||
newStore, err := scheduler.NewJSONStore(tempFile)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
defer newStore.Close()
|
||||
|
||||
retrieved, err := newStore.Get(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(retrieved.ID).To(Equal(task.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Scheduler Execution", func() {
|
||||
It("should execute a due task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
err := sched.CreateTask(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "2s", "100ms").Should(Equal(1))
|
||||
|
||||
Expect(executor.executedTasks[0]).To(Equal("test-agent:test prompt"))
|
||||
|
||||
// Verify task run was logged
|
||||
runs, err := sched.GetTaskRuns(task.ID, 10)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(runs).To(HaveLen(1))
|
||||
Expect(runs[0].Status).To(Equal("success"))
|
||||
|
||||
// Verify one-time task was deleted
|
||||
_, err = sched.GetTask(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should execute recurring tasks multiple times", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "recurring", scheduler.ScheduleTypeInterval, "500")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second)
|
||||
err := sched.CreateTask(task)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "3s", "100ms").Should(BeNumerically(">=", 2))
|
||||
|
||||
// Verify task is still active
|
||||
updatedTask, err := sched.GetTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(updatedTask.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
})
|
||||
|
||||
It("should handle task execution errors", func() {
|
||||
executor.shouldError = true
|
||||
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
sched.CreateTask(task)
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Eventually(func() int {
|
||||
runs, _ := sched.GetTaskRuns(task.ID, 10)
|
||||
return len(runs)
|
||||
}, "2s", "100ms").Should(Equal(1))
|
||||
|
||||
runs, _ := sched.GetTaskRuns(task.ID, 10)
|
||||
Expect(runs[0].Status).To(Equal("error"))
|
||||
Expect(runs[0].Error).NotTo(BeEmpty())
|
||||
})
|
||||
|
||||
It("should not execute paused tasks", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, "0s")
|
||||
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
|
||||
task.Status = scheduler.TaskStatusPaused
|
||||
sched.CreateTask(task)
|
||||
|
||||
// Scheduler is already started in BeforeEach
|
||||
|
||||
Consistently(func() int {
|
||||
return len(executor.executedTasks)
|
||||
}, "1s", "100ms").Should(Equal(0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Task Management", func() {
|
||||
It("should pause and resume a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
sched.CreateTask(task)
|
||||
|
||||
err := sched.PauseTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
paused, _ := sched.GetTask(task.ID)
|
||||
Expect(paused.Status).To(Equal(scheduler.TaskStatusPaused))
|
||||
|
||||
err = sched.ResumeTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
resumed, _ := sched.GetTask(task.ID)
|
||||
Expect(resumed.Status).To(Equal(scheduler.TaskStatusActive))
|
||||
Expect(resumed.NextRun).NotTo(BeZero())
|
||||
})
|
||||
|
||||
It("should get tasks by agent", func() {
|
||||
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(sched.CreateTask(task1)).To(Succeed())
|
||||
Expect(sched.CreateTask(task2)).To(Succeed())
|
||||
Expect(sched.CreateTask(task3)).To(Succeed())
|
||||
|
||||
agent1Tasks, err := sched.GetTasksByAgent("agent1")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(agent1Tasks).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("should delete a task", func() {
|
||||
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
|
||||
sched.CreateTask(task)
|
||||
|
||||
err := sched.DeleteTask(task.ID)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = sched.GetTask(task.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
var dayPattern = regexp.MustCompile(`^(\d+)d(.*)$`)
|
||||
|
||||
// ParseDuration extends time.ParseDuration with support for days ("d").
|
||||
// Examples: "1d" = 24h, "2d12h" = 60h, "30m", "2h30m".
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
if m := dayPattern.FindStringSubmatch(s); m != nil {
|
||||
days, err := strconv.Atoi(m[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||
}
|
||||
d := time.Duration(days) * 24 * time.Hour
|
||||
if m[2] != "" {
|
||||
rest, err := time.ParseDuration(m[2])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
d += rest
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusActive TaskStatus = "active"
|
||||
TaskStatusPaused TaskStatus = "paused"
|
||||
)
|
||||
|
||||
type ScheduleType string
|
||||
|
||||
const (
|
||||
ScheduleTypeCron ScheduleType = "cron"
|
||||
ScheduleTypeInterval ScheduleType = "interval"
|
||||
ScheduleTypeOnce ScheduleType = "once"
|
||||
)
|
||||
|
||||
// Task represents a scheduled task
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
Prompt string `json:"prompt"`
|
||||
ScheduleType ScheduleType `json:"schedule_type"`
|
||||
ScheduleValue string `json:"schedule_value"`
|
||||
Status TaskStatus `json:"status"`
|
||||
NextRun time.Time `json:"next_run"`
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ContextMode string `json:"context_mode"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// TaskRun represents a single execution of a task
|
||||
type TaskRun struct {
|
||||
ID string `json:"id"`
|
||||
TaskID string `json:"task_id"`
|
||||
RunAt time.Time `json:"run_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Status string `json:"status"` // "success", "error", "timeout"
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewTask creates a new task with the given parameters
|
||||
func NewTask(agentName, prompt string, scheduleType ScheduleType, scheduleValue string) (*Task, error) {
|
||||
task := &Task{
|
||||
ID: uuid.New().String(),
|
||||
AgentName: agentName,
|
||||
Prompt: prompt,
|
||||
ScheduleType: scheduleType,
|
||||
ScheduleValue: scheduleValue,
|
||||
Status: TaskStatusActive,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ContextMode: "agent",
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if err := task.CalculateNextRun(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// CalculateNextRun calculates the next run time based on schedule type
|
||||
func (t *Task) CalculateNextRun() error {
|
||||
now := time.Now()
|
||||
|
||||
switch t.ScheduleType {
|
||||
case ScheduleTypeCron:
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(t.ScheduleValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid cron expression: %w", err)
|
||||
}
|
||||
t.NextRun = schedule.Next(now)
|
||||
|
||||
case ScheduleTypeInterval:
|
||||
intervalMs, err := strconv.ParseInt(t.ScheduleValue, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval: %w", err)
|
||||
}
|
||||
if intervalMs <= 0 {
|
||||
return fmt.Errorf("invalid interval: %d", intervalMs)
|
||||
}
|
||||
if t.LastRun != nil {
|
||||
t.NextRun = t.LastRun.Add(time.Duration(intervalMs) * time.Millisecond)
|
||||
} else {
|
||||
t.NextRun = now.Add(time.Duration(intervalMs) * time.Millisecond)
|
||||
}
|
||||
|
||||
case ScheduleTypeOnce:
|
||||
duration, err := ParseDuration(t.ScheduleValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
if duration < 0 {
|
||||
return fmt.Errorf("duration must be positive: %s", t.ScheduleValue)
|
||||
}
|
||||
t.NextRun = now.Add(duration)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown schedule type: %s", t.ScheduleType)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDue checks if the task should be executed now
|
||||
func (t *Task) IsDue() bool {
|
||||
return t.Status == TaskStatusActive && time.Now().After(t.NextRun)
|
||||
}
|
||||
|
||||
// NewTaskRun creates a new task run record
|
||||
func NewTaskRun(taskID string) *TaskRun {
|
||||
return &TaskRun{
|
||||
ID: uuid.New().String(),
|
||||
TaskID: taskID,
|
||||
RunAt: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/localrag"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// datePrefixRegex matches YYYY-MM-DD at the start of a filename (e.g. 2006-01-02-15-04-05-hash.txt).
|
||||
var datePrefixRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
|
||||
|
||||
// summaryPrefix is the filename prefix for compaction summary entries; skip re-compacting these.
|
||||
const summaryPrefix = "summary-"
|
||||
|
||||
// bucketKey returns the period bucket key for a date string (YYYY-MM-DD).
|
||||
func bucketKey(dateStr, period string) (string, error) {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch period {
|
||||
case "daily":
|
||||
return dateStr, nil
|
||||
case "weekly":
|
||||
year, week := t.ISOWeek()
|
||||
return fmt.Sprintf("%04d-W%02d", year, week), nil
|
||||
case "monthly":
|
||||
return t.Format("2006-01"), nil
|
||||
default:
|
||||
return dateStr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// dateFromFilename extracts YYYY-MM-DD from the start of a filename if present.
|
||||
func dateFromFilename(filename string) (string, bool) {
|
||||
base := filepath.Base(filename)
|
||||
matches := datePrefixRegex.FindStringSubmatch(base)
|
||||
if len(matches) < 2 {
|
||||
return "", false
|
||||
}
|
||||
return matches[1], true
|
||||
}
|
||||
|
||||
// groupEntriesByPeriod groups entry names by period bucket (daily/weekly/monthly). Skips summary-* and entries without a parseable date.
|
||||
func groupEntriesByPeriod(entries []string, period string) map[string][]string {
|
||||
groups := make(map[string][]string)
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(filepath.Base(entry), summaryPrefix) {
|
||||
continue
|
||||
}
|
||||
dateStr, ok := dateFromFilename(entry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, err := bucketKey(dateStr, period)
|
||||
if err != nil {
|
||||
xlog.Debug("compaction: skip entry, invalid date", "entry", entry, "error", err)
|
||||
continue
|
||||
}
|
||||
groups[key] = append(groups[key], entry)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// summarizer summarizes text via the LLM.
|
||||
type summarizer interface {
|
||||
Summarize(ctx context.Context, content string) (string, error)
|
||||
}
|
||||
|
||||
type openAISummarizer struct {
|
||||
client *openai.Client
|
||||
model string
|
||||
}
|
||||
|
||||
func (s *openAISummarizer) Summarize(ctx context.Context, content string) (string, error) {
|
||||
if content == "" {
|
||||
return "", nil
|
||||
}
|
||||
resp, err := s.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
|
||||
Model: s.model,
|
||||
Messages: []openai.ChatCompletionMessage{
|
||||
{Role: openai.ChatMessageRoleSystem, Content: "Summarize the following knowledge base entries into a concise summary. Preserve important facts and key points."},
|
||||
{Role: openai.ChatMessageRoleUser, Content: content},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return "", fmt.Errorf("no completion choices")
|
||||
}
|
||||
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
// RunCompaction runs one compaction pass: list entries, group by period, for each group fetch content, optionally summarize, store result, delete originals.
|
||||
func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
collection := client.Collection()
|
||||
entries, err := client.Client.ListEntries(collection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list entries: %w", err)
|
||||
}
|
||||
groups := groupEntriesByPeriod(entries, period)
|
||||
if len(groups) == 0 {
|
||||
xlog.Debug("compaction: no groups to compact", "collection", collection, "period", period)
|
||||
return nil
|
||||
}
|
||||
|
||||
var sum summarizer
|
||||
if summarize && apiURL != "" && model != "" {
|
||||
openAIClient := llm.NewClient(apiKey, apiURL, "120s")
|
||||
sum = &openAISummarizer{client: openAIClient, model: model}
|
||||
}
|
||||
|
||||
for key, groupEntries := range groups {
|
||||
if len(groupEntries) == 0 {
|
||||
continue
|
||||
}
|
||||
var combined strings.Builder
|
||||
for _, entry := range groupEntries {
|
||||
entryContent, _, err := client.GetEntryContent(entry)
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: get entry content failed", "entry", entry, "error", err)
|
||||
continue
|
||||
}
|
||||
if entryContent != "" {
|
||||
combined.WriteString(entryContent)
|
||||
combined.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
content := strings.TrimSpace(combined.String())
|
||||
if content == "" {
|
||||
xlog.Debug("compaction: empty content for group", "key", key)
|
||||
continue
|
||||
}
|
||||
|
||||
if sum != nil {
|
||||
summary, err := sum.Summarize(ctx, content)
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: summarize failed", "key", key, "error", err)
|
||||
continue
|
||||
}
|
||||
content = summary
|
||||
}
|
||||
|
||||
// Store result as summary-<key>.txt
|
||||
resultFilename := fmt.Sprintf("%s%s.txt", summaryPrefix, key)
|
||||
tmpDir, err := os.MkdirTemp("", "localagi-compact")
|
||||
if err != nil {
|
||||
xlog.Warn("compaction: mkdir temp failed", "error", err)
|
||||
continue
|
||||
}
|
||||
tmpPath := filepath.Join(tmpDir, resultFilename)
|
||||
if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
xlog.Warn("compaction: write temp file failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if err := client.Client.Store(collection, tmpPath); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
xlog.Warn("compaction: store failed", "key", key, "error", err)
|
||||
continue
|
||||
}
|
||||
os.RemoveAll(tmpDir)
|
||||
|
||||
for _, entry := range groupEntries {
|
||||
if _, err := client.Client.DeleteEntry(collection, entry); err != nil {
|
||||
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
|
||||
}
|
||||
}
|
||||
xlog.Info("compaction: compacted group", "collection", collection, "period", period, "key", key, "entries", len(groupEntries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done.
|
||||
func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) {
|
||||
// Run first compaction immediately on startup
|
||||
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
|
||||
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
|
||||
}
|
||||
|
||||
interval := 24 * time.Hour
|
||||
switch config.KBCompactionInterval {
|
||||
case "weekly":
|
||||
interval = 7 * 24 * time.Hour
|
||||
case "monthly":
|
||||
interval = 30 * 24 * time.Hour
|
||||
default:
|
||||
interval = 24 * time.Hour
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
xlog.Debug("compaction ticker stopped", "collection", client.Collection())
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
|
||||
xlog.Warn("compaction ticker failed", "collection", client.Collection(), "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-23
@@ -3,6 +3,7 @@ package state
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
@@ -10,6 +11,21 @@ import (
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
)
|
||||
|
||||
// parseIntField parses an integer field that may be received as either a number or a string
|
||||
func parseIntField(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ConnectorConfig struct {
|
||||
Type string `json:"type"` // e.g. Slack
|
||||
Config string `json:"config"`
|
||||
@@ -43,18 +59,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"`
|
||||
@@ -62,18 +80,27 @@ type AgentConfig struct {
|
||||
RandomIdentity bool `json:"random_identity" form:"random_identity"`
|
||||
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
|
||||
CanPlan bool `json:"enable_planning" form:"enable_planning"`
|
||||
PlanReviewerModel string `json:"plan_reviewer_model" form:"plan_reviewer_model"`
|
||||
DisableSinkState bool `json:"disable_sink_state" form:"disable_sink_state"`
|
||||
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
|
||||
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
|
||||
SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"`
|
||||
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
|
||||
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
|
||||
EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"`
|
||||
KBCompactionInterval string `json:"kb_compaction_interval" form:"kb_compaction_interval"`
|
||||
KBCompactionSummarize bool `json:"kb_compaction_summarize" form:"kb_compaction_summarize"`
|
||||
KBAutoSearch bool `json:"kb_auto_search" form:"kb_auto_search"`
|
||||
KBAsTools bool `json:"kb_as_tools" form:"kb_as_tools"`
|
||||
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
|
||||
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
|
||||
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
|
||||
LoopDetectionSteps int `json:"loop_detection_steps" form:"loop_detection_steps"`
|
||||
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
|
||||
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
||||
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
||||
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
||||
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
|
||||
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
|
||||
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
|
||||
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
|
||||
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
|
||||
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
|
||||
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
|
||||
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
|
||||
@@ -146,6 +173,34 @@ 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: "plan_reviewer_model",
|
||||
Label: "Plan Reviewer Model",
|
||||
Type: "text",
|
||||
DefaultValue: "",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "api_url",
|
||||
Label: "API URL",
|
||||
@@ -190,6 +245,31 @@ func NewAgentConfigMeta(
|
||||
Step: 1,
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_kb_compaction",
|
||||
Label: "Enable KB Compaction",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Periodically group collection entries by date (daily/weekly/monthly), optionally summarize or concatenate, then store and remove originals",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_compaction_interval",
|
||||
Label: "KB Compaction Interval",
|
||||
Type: "text",
|
||||
DefaultValue: "daily",
|
||||
Placeholder: "daily, weekly, monthly",
|
||||
HelpText: "Compaction window: daily, weekly, or monthly",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_compaction_summarize",
|
||||
Label: "KB Compaction Summarize",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "When enabled, summarize grouped content via LLM; when disabled, store concatenated content only (no LLM call)",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "long_term_memory",
|
||||
Label: "Long Term Memory",
|
||||
@@ -204,6 +284,35 @@ func NewAgentConfigMeta(
|
||||
DefaultValue: false,
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_auto_search",
|
||||
Label: "KB Auto Search",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "Automatically search knowledge base when a user message is received",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "kb_as_tools",
|
||||
Label: "KB As Tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Inject knowledge base search and add actions as tools, allowing the agent to access its memory without manual configuration",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "conversation_storage_mode",
|
||||
Label: "Conversation Storage Mode",
|
||||
Type: "select",
|
||||
DefaultValue: "user_only",
|
||||
Options: []config.FieldOption{
|
||||
{Value: "user_only", Label: "User Messages Only"},
|
||||
{Value: "user_and_assistant", Label: "User and Assistant Messages"},
|
||||
{Value: "whole_conversation", Label: "Whole Conversation as Block"},
|
||||
},
|
||||
HelpText: "Controls what gets stored in the knowledge base: only user messages, user and assistant messages separately, or the entire conversation as a single block",
|
||||
Tags: config.Tags{Section: "MemorySettings"},
|
||||
},
|
||||
{
|
||||
Name: "system_prompt",
|
||||
Label: "System Prompt",
|
||||
@@ -261,6 +370,15 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Duration for scheduling periodic agent runs",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "scheduler_poll_interval",
|
||||
Label: "Scheduler Poll Interval",
|
||||
Type: "text",
|
||||
DefaultValue: "30s",
|
||||
Placeholder: "30s",
|
||||
HelpText: "Duration for polling the scheduler for planned tasks",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_reasoning",
|
||||
Label: "Enable Reasoning",
|
||||
@@ -270,12 +388,11 @@ func NewAgentConfigMeta(
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "loop_detection_steps",
|
||||
Label: "Max Loop Detection Steps",
|
||||
Type: "number",
|
||||
DefaultValue: 5,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
Name: "enable_guided_tools",
|
||||
Label: "Enable Guided Tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Filter tools through guidance using their descriptions; creates virtual guidelines when none exist",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
@@ -288,21 +405,29 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Number of concurrent tasks that can run in parallel",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "disable_sink_state",
|
||||
Label: "Disable Sink State",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Disable the sink state of the agent",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "mcp_stdio_servers",
|
||||
Label: "MCP STDIO Servers",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "JSON configuration for MCP STDIO servers",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
Tags: config.Tags{Section: "MCP"},
|
||||
},
|
||||
{
|
||||
Name: "mcp_prepare_script",
|
||||
Label: "MCP Prepare Script",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Script to prepare the MCP box",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
HelpText: "Script to prepare for running MCP servers",
|
||||
Tags: config.Tags{Section: "MCP"},
|
||||
},
|
||||
{
|
||||
Name: "strip_thinking_tags",
|
||||
@@ -373,6 +498,9 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
aux := &struct {
|
||||
*Alias
|
||||
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
|
||||
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
KnowledgeBaseResults interface{} `json:"kb_results"`
|
||||
}{
|
||||
Alias: (*Alias)(a),
|
||||
}
|
||||
@@ -381,6 +509,11 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse integer fields that may come as strings
|
||||
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
|
||||
a.ParallelJobs = parseIntField(aux.ParallelJobs)
|
||||
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
|
||||
|
||||
// Handle MCP STDIO servers configuration
|
||||
if aux.MCPSTDIOServersConfig != nil {
|
||||
switch v := aux.MCPSTDIOServersConfig.(type) {
|
||||
@@ -399,7 +532,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(mcpConfig.MCPServers))
|
||||
for _, server := range mcpConfig.MCPServers {
|
||||
for name, server := range mcpConfig.MCPServers {
|
||||
// Convert env map to slice of "KEY=VALUE" strings
|
||||
envSlice := make([]string, 0, len(server.Env))
|
||||
for k, v := range server.Env {
|
||||
@@ -407,6 +540,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: server.Command,
|
||||
Args: server.Args,
|
||||
Env: envSlice,
|
||||
@@ -421,6 +555,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return fmt.Errorf("invalid server configuration format")
|
||||
}
|
||||
|
||||
name, _ := serverMap["name"].(string)
|
||||
cmd, _ := serverMap["cmd"].(string)
|
||||
args := make([]string, 0)
|
||||
if argsInterface, ok := serverMap["args"].([]interface{}); ok {
|
||||
@@ -441,6 +576,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: cmd,
|
||||
Args: args,
|
||||
Env: env,
|
||||
@@ -489,7 +625,11 @@ func (a *AgentConfig) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = struct {
|
||||
key := server.Name
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("server%d", i)
|
||||
}
|
||||
mcpConfig.MCPServers[key] = struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
|
||||
+130
-167
@@ -2,7 +2,6 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -15,32 +14,29 @@ import (
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/localrag"
|
||||
"github.com/mudler/LocalAGI/pkg/utils"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
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
|
||||
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 +70,7 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
||||
}
|
||||
|
||||
func NewAgentPool(
|
||||
defaultModel, defaultMultimodalModel, imageModel, apiURL, apiKey, directory, mcpBoxURL string,
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
|
||||
LocalRAGAPI string,
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
||||
connectors func(*AgentConfig) []Connector,
|
||||
@@ -92,29 +88,29 @@ func NewAgentPool(
|
||||
if withLogs {
|
||||
conversationPath = filepath.Join(directory, "conversations")
|
||||
}
|
||||
|
||||
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,
|
||||
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 +119,26 @@ 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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -166,14 +163,7 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
go func(ac AgentConfig) {
|
||||
// Create the agent avatar
|
||||
if err := createAgentAvatar(a.apiURL, a.apiKey, a.defaultModel, a.imageModel, a.pooldir, ac); err != nil {
|
||||
xlog.Error("Failed to create agent avatar", "error", err)
|
||||
}
|
||||
}(a.pool[name])
|
||||
|
||||
return a.startAgentWithConfig(name, agentConfig, nil)
|
||||
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
|
||||
}
|
||||
|
||||
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
@@ -209,7 +199,7 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.startAgentWithConfig(name, agentConfig, obs); err != nil {
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
|
||||
if obs != nil {
|
||||
o.Completion = &types.Completion{Error: err.Error()}
|
||||
obs.Update(*o)
|
||||
@@ -225,84 +215,6 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
|
||||
client := llm.NewClient(APIKey, APIURL+"/v1", "10m")
|
||||
|
||||
if imageModel == "" {
|
||||
return fmt.Errorf("image model not set")
|
||||
}
|
||||
|
||||
if model == "" {
|
||||
return fmt.Errorf("default model not set")
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(avatarDir, "avatars", fmt.Sprintf("%s.png", agent.Name))
|
||||
if _, err := os.Stat(imagePath); err == nil {
|
||||
// Image already exists
|
||||
xlog.Debug("Avatar already exists", "path", imagePath)
|
||||
return nil
|
||||
}
|
||||
|
||||
var results struct {
|
||||
ImagePrompt string `json:"image_prompt"`
|
||||
}
|
||||
|
||||
err := llm.GenerateTypedJSONWithGuidance(
|
||||
context.Background(),
|
||||
llm.NewClient(APIKey, APIURL, "10m"),
|
||||
"Generate a prompt that I can use to create a random avatar for the bot '"+agent.Name+"', the description of the bot is: "+agent.Description,
|
||||
model,
|
||||
jsonschema.Definition{
|
||||
Type: jsonschema.Object,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"image_prompt": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The prompt to generate the image",
|
||||
},
|
||||
},
|
||||
Required: []string{"image_prompt"},
|
||||
}, &results)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate image prompt: %w", err)
|
||||
}
|
||||
|
||||
if results.ImagePrompt == "" {
|
||||
xlog.Error("Failed to generate image prompt")
|
||||
return fmt.Errorf("failed to generate image prompt")
|
||||
}
|
||||
|
||||
req := openai.ImageRequest{
|
||||
Prompt: results.ImagePrompt,
|
||||
Model: imageModel,
|
||||
Size: openai.CreateImageSize256x256,
|
||||
ResponseFormat: openai.CreateImageResponseFormatB64JSON,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.CreateImage(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate image: %w", err)
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 {
|
||||
return fmt.Errorf("failed to generate image")
|
||||
}
|
||||
|
||||
imageJson := resp.Data[0].B64JSON
|
||||
|
||||
os.MkdirAll(filepath.Join(avatarDir, "avatars"), 0755)
|
||||
|
||||
// Save the image to the agent directory
|
||||
imageData, err := base64.StdEncoding.DecodeString(imageJson)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(imagePath, imageData, 0644)
|
||||
}
|
||||
|
||||
func (a *AgentPool) List() []string {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
@@ -324,7 +236,7 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
||||
return a.agentStatus[name]
|
||||
}
|
||||
|
||||
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
|
||||
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
|
||||
var manager sse.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
@@ -334,25 +246,39 @@ 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"
|
||||
}
|
||||
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// XXX: Why do we update the pool config from an Agent's config?
|
||||
if config.APIURL != "" {
|
||||
a.apiURL = config.APIURL
|
||||
@@ -415,14 +341,18 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
|
||||
opts := []Option{
|
||||
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
|
||||
WithModel(model),
|
||||
WithLLMAPIURL(a.apiURL),
|
||||
WithContext(ctx),
|
||||
WithMCPServers(config.MCPServers...),
|
||||
WithTranscriptionModel(transcriptionModel),
|
||||
WithTranscriptionLanguage(transcriptionLanguage),
|
||||
WithTTSModel(ttsModel),
|
||||
WithPeriodicRuns(config.PeriodicRuns),
|
||||
WithSchedulerPollInterval(config.SchedulerPollInterval),
|
||||
WithPermanentGoal(config.PermanentGoal),
|
||||
WithMCPSTDIOServers(config.MCPSTDIOServers...),
|
||||
WithMCPBoxURL(a.mcpBoxURL),
|
||||
WithPrompts(promptBlocks...),
|
||||
WithJobFilters(filters...),
|
||||
WithMCPPrepareScript(config.MCPPrepareScript),
|
||||
@@ -437,7 +367,6 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
WithCharacterFile(characterFile),
|
||||
WithLLMAPIKey(a.apiKey),
|
||||
WithTimeout(a.timeout),
|
||||
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
|
||||
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
|
||||
xlog.Info(
|
||||
"Agent is thinking",
|
||||
@@ -518,6 +447,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnableSummaryMemory)
|
||||
}
|
||||
|
||||
if config.ConversationStorageMode != "" {
|
||||
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
|
||||
}
|
||||
|
||||
if config.CanStopItself {
|
||||
opts = append(opts, CanStopItself)
|
||||
}
|
||||
@@ -526,6 +459,14 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnablePlanning)
|
||||
}
|
||||
|
||||
if config.PlanReviewerModel != "" {
|
||||
opts = append(opts, WithPlanReviewerLLM(config.PlanReviewerModel))
|
||||
}
|
||||
|
||||
if config.DisableSinkState {
|
||||
opts = append(opts, DisableSinkState)
|
||||
}
|
||||
|
||||
if config.InitiateConversations {
|
||||
opts = append(opts, EnableInitiateConversations)
|
||||
}
|
||||
@@ -538,14 +479,38 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}
|
||||
|
||||
var ragClient *localrag.WrappedClient
|
||||
if config.EnableKnowledgeBase {
|
||||
opts = append(opts, EnableKnowledgeBase)
|
||||
ragClient = localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)
|
||||
opts = append(opts, WithRAGDB(ragClient), EnableKnowledgeBase)
|
||||
// Set KB auto search option (defaults to true for backward compatibility)
|
||||
// For backward compatibility: if both new KB fields are false (zero values),
|
||||
// assume this is an old config and default KBAutoSearch to true
|
||||
kbAutoSearch := config.KBAutoSearch
|
||||
if !config.KBAutoSearch && !config.KBAsTools {
|
||||
// Both new fields are false, likely an old config - default to true for backward compatibility
|
||||
kbAutoSearch = true
|
||||
}
|
||||
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
|
||||
// Inject KB wrapper actions if enabled
|
||||
if config.KBAsTools && ragClient != nil {
|
||||
kbResults := config.KnowledgeBaseResults
|
||||
if kbResults <= 0 {
|
||||
kbResults = 5 // Default
|
||||
}
|
||||
searchAction, addAction := NewKBWrapperActions(ragClient, kbResults)
|
||||
opts = append(opts, WithActions(searchAction, addAction))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableReasoning {
|
||||
opts = append(opts, EnableForceReasoning)
|
||||
}
|
||||
|
||||
if config.EnableGuidedTools {
|
||||
opts = append(opts, EnableGuidedTools)
|
||||
}
|
||||
|
||||
if config.StripThinkingTags {
|
||||
opts = append(opts, EnableStripThinkingTags)
|
||||
}
|
||||
@@ -554,19 +519,16 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
opts = append(opts, EnableKnowledgeBaseWithResults(config.KnowledgeBaseResults))
|
||||
}
|
||||
|
||||
if config.LoopDetectionSteps > 0 {
|
||||
opts = append(opts, WithLoopDetectionSteps(config.LoopDetectionSteps))
|
||||
}
|
||||
|
||||
if config.ParallelJobs > 0 {
|
||||
opts = append(opts, WithParallelJobs(config.ParallelJobs))
|
||||
}
|
||||
|
||||
if config.EnableEvaluation {
|
||||
opts = append(opts, EnableEvaluation())
|
||||
if config.MaxEvaluationLoops > 0 {
|
||||
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
|
||||
}
|
||||
}
|
||||
|
||||
if config.MaxEvaluationLoops > 0 {
|
||||
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
|
||||
}
|
||||
|
||||
xlog.Info("Starting agent", "name", name, "config", config)
|
||||
@@ -585,6 +547,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
|
||||
}
|
||||
}()
|
||||
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil {
|
||||
go runCompactionTicker(ctx, ragClient, config, a.apiURL, a.apiKey, model)
|
||||
}
|
||||
|
||||
xlog.Info("Starting connectors", "name", name, "config", config)
|
||||
|
||||
for _, c := range connectors {
|
||||
@@ -613,7 +579,7 @@ func (a *AgentPool) StartAll() error {
|
||||
if a.agents[name] != nil { // Agent already started
|
||||
continue
|
||||
}
|
||||
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
|
||||
xlog.Error("Failed to start agent", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -651,7 +617,7 @@ func (a *AgentPool) Start(name string) error {
|
||||
return nil
|
||||
}
|
||||
if config, ok := a.pool[name]; ok {
|
||||
return a.startAgentWithConfig(name, &config, nil)
|
||||
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
|
||||
}
|
||||
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
@@ -677,9 +643,6 @@ func (a *AgentPool) Remove(name string) error {
|
||||
delete(a.agents, name)
|
||||
delete(a.pool, name)
|
||||
|
||||
// remove avatar
|
||||
os.Remove(filepath.Join(a.pooldir, "avatars", fmt.Sprintf("%s.png", name)))
|
||||
|
||||
if err := a.save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+35
-1
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
@@ -88,11 +89,36 @@ func (a ActionDefinition) ToFunctionDefinition() *openai.FunctionDefinition {
|
||||
}
|
||||
}
|
||||
|
||||
type cogitoWrapper struct {
|
||||
action Action
|
||||
ctx context.Context
|
||||
sharedState *AgentSharedState
|
||||
}
|
||||
|
||||
func (c *cogitoWrapper) Tool() openai.Tool {
|
||||
return openai.Tool{
|
||||
Type: openai.ToolTypeFunction,
|
||||
Function: c.action.Definition().ToFunctionDefinition(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cogitoWrapper) Execute(args map[string]any) (string, any, error) {
|
||||
ctx := c.ctx
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
result, err := c.action.Run(ctx, c.sharedState, ActionParams(args))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return result.Result, result, nil
|
||||
}
|
||||
|
||||
// Actions is something the agent can do
|
||||
type Action interface {
|
||||
Run(ctx context.Context, sharedState *AgentSharedState, action ActionParams) (ActionResult, error)
|
||||
Definition() ActionDefinition
|
||||
Plannable() bool
|
||||
}
|
||||
|
||||
// UserDefinedChecker interface to identify user-defined actions
|
||||
@@ -162,6 +188,14 @@ func (a Actions) ToTools() []openai.Tool {
|
||||
return tools
|
||||
}
|
||||
|
||||
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.ToolDefinitionInterface {
|
||||
tools := []cogito.ToolDefinitionInterface{}
|
||||
for _, action := range a {
|
||||
tools = append(tools, &cogitoWrapper{action: action, ctx: ctx, sharedState: sharedState})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func (a Actions) Find(name string) Action {
|
||||
for _, action := range a {
|
||||
if action.Definition().Name.Is(name) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// ConversationMessage represents a message with associated metadata
|
||||
// Used when the agent initiates new conversations to preserve context
|
||||
// such as generated images, files, or URLs
|
||||
type ConversationMessage struct {
|
||||
Message openai.ChatCompletionMessage
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// NewConversationMessage creates a new ConversationMessage with the given message
|
||||
func NewConversationMessage(msg openai.ChatCompletionMessage) *ConversationMessage {
|
||||
return &ConversationMessage{
|
||||
Message: msg,
|
||||
Metadata: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithMetadata adds metadata to the conversation message
|
||||
func (c *ConversationMessage) WithMetadata(metadata map[string]interface{}) *ConversationMessage {
|
||||
c.Metadata = metadata
|
||||
return c
|
||||
}
|
||||
+6
-40
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -20,19 +21,15 @@ type Job struct {
|
||||
UUID string
|
||||
Metadata map[string]interface{}
|
||||
DoneFilter bool
|
||||
|
||||
|
||||
// Tools available for this job
|
||||
BuiltinTools []ActionDefinition // Built-in tools like web search
|
||||
UserTools []ActionDefinition // User-defined function tools
|
||||
ToolChoice string
|
||||
|
||||
pastActions []*ActionRequest
|
||||
nextAction *Action
|
||||
nextActionParams *ActionParams
|
||||
nextActionReasoning string
|
||||
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
context context.Context
|
||||
fragment *cogito.Fragment
|
||||
cancel context.CancelFunc
|
||||
|
||||
Obs *Observable
|
||||
}
|
||||
@@ -80,7 +77,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMetadata(metadata map[string]interface{}) JobOption {
|
||||
func WithMetadata(metadata map[string]any) JobOption {
|
||||
return func(j *Job) {
|
||||
j.Metadata = metadata
|
||||
}
|
||||
@@ -108,37 +105,6 @@ func (j *Job) CallbackWithResult(stateResult ActionState) {
|
||||
j.ResultCallback(stateResult)
|
||||
}
|
||||
|
||||
func (j *Job) SetNextAction(action *Action, params *ActionParams, reasoning string) {
|
||||
j.nextAction = action
|
||||
j.nextActionParams = params
|
||||
j.nextActionReasoning = reasoning
|
||||
}
|
||||
|
||||
func (j *Job) AddPastAction(action Action, params *ActionParams) {
|
||||
j.pastActions = append(j.pastActions, &ActionRequest{
|
||||
Action: action,
|
||||
Params: params,
|
||||
})
|
||||
}
|
||||
|
||||
func (j *Job) GetPastActions() []*ActionRequest {
|
||||
return j.pastActions
|
||||
}
|
||||
|
||||
func (j *Job) GetNextAction() (*Action, *ActionParams, string) {
|
||||
return j.nextAction, j.nextActionParams, j.nextActionReasoning
|
||||
}
|
||||
|
||||
func (j *Job) HasNextAction() bool {
|
||||
return j.nextAction != nil
|
||||
}
|
||||
|
||||
func (j *Job) ResetNextAction() {
|
||||
j.nextAction = nil
|
||||
j.nextActionParams = nil
|
||||
j.nextActionReasoning = ""
|
||||
}
|
||||
|
||||
func WithTextImage(text, image string) JobOption {
|
||||
return func(j *Job) {
|
||||
j.ConversationHistory = append(j.ConversationHistory, openai.ChatCompletionMessage{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ type Completion struct {
|
||||
Conversation []openai.ChatCompletionMessage `json:"conversation,omitempty"`
|
||||
ActionResult string `json:"action_result,omitempty"`
|
||||
AgentState *AgentInternalState `json:"agent_state,omitempty"`
|
||||
FilterResult *FilterResult `json:"filter_result,omitempty"`
|
||||
FilterResult *FilterResult `json:"filter_result,omitempty"`
|
||||
}
|
||||
|
||||
type Observable struct {
|
||||
|
||||
+10
-3
@@ -1,8 +1,10 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -11,6 +13,7 @@ type JobResult struct {
|
||||
sync.Mutex
|
||||
// The result of a job
|
||||
State []ActionState
|
||||
Plans []cogito.PlanStatus
|
||||
Conversation []openai.ChatCompletionMessage
|
||||
|
||||
Finalizers []func([]openai.ChatCompletionMessage)
|
||||
@@ -59,9 +62,13 @@ func (j *JobResult) SetResponse(response string) {
|
||||
}
|
||||
|
||||
// WaitResult waits for the result of a job
|
||||
func (j *JobResult) WaitResult() *JobResult {
|
||||
<-j.ready
|
||||
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-j.ready:
|
||||
}
|
||||
j.Lock()
|
||||
defer j.Unlock()
|
||||
return j
|
||||
return j, nil
|
||||
}
|
||||
|
||||
+27
-8
@@ -5,8 +5,19 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/conversations"
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
)
|
||||
|
||||
// Forward declaration to avoid circular import
|
||||
type TaskScheduler interface {
|
||||
CreateTask(task *scheduler.Task) error
|
||||
GetAllTasks() ([]*scheduler.Task, error)
|
||||
GetTask(id string) (*scheduler.Task, error)
|
||||
DeleteTask(id string) error
|
||||
PauseTask(id string) error
|
||||
ResumeTask(id string) error
|
||||
}
|
||||
|
||||
// State is the structure
|
||||
// that is used to keep track of the current state
|
||||
// and the Agent's short memory that it can update
|
||||
@@ -29,17 +40,26 @@ const (
|
||||
DefaultLastMessageDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
type ReminderActionResponse struct {
|
||||
Message string `json:"message"`
|
||||
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
|
||||
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
|
||||
NextRun time.Time `json:"next_run"` // Next scheduled run time
|
||||
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
|
||||
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
|
||||
type RecurringReminderParams struct {
|
||||
Message string `json:"message"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
}
|
||||
|
||||
// OneTimeReminderParams are the parameters the LLM provides for set_onetime_reminder.
|
||||
type OneTimeReminderParams struct {
|
||||
Message string `json:"message"`
|
||||
Delay string `json:"delay"` // Go duration format with day support: "30m", "2h", "1d", "1d12h"
|
||||
}
|
||||
|
||||
// ReminderActionResponse is kept for backward compatibility.
|
||||
// Deprecated: use RecurringReminderParams or OneTimeReminderParams.
|
||||
type ReminderActionResponse = RecurringReminderParams
|
||||
|
||||
type AgentSharedState struct {
|
||||
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
|
||||
Reminders []ReminderActionResponse `json:"reminders"`
|
||||
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
|
||||
AgentName string `json:"agent_name"`
|
||||
}
|
||||
|
||||
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
@@ -48,7 +68,6 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
}
|
||||
return &AgentSharedState{
|
||||
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
|
||||
Reminders: make([]ReminderActionResponse, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,16 @@ services:
|
||||
- /dev/dri
|
||||
- /dev/kfd
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -12,16 +12,16 @@ services:
|
||||
- /dev/dri/card1
|
||||
- /dev/dri/renderD129
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -17,16 +17,16 @@ services:
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
mcpbox:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: mcpbox
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall-postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-postgres
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
+45
-32
@@ -8,8 +8,7 @@ services:
|
||||
image: localai/localai:master
|
||||
command:
|
||||
- ${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
|
||||
- granite-embedding-107m-multilingual
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
|
||||
@@ -22,23 +21,46 @@ services:
|
||||
- DEBUG=true
|
||||
#- LOCALAI_API_KEY=sk-1234567890
|
||||
volumes:
|
||||
- ./volumes/models:/models
|
||||
- ./volumes/backends:/backends
|
||||
- ./volumes/images:/tmp/generated/images
|
||||
- models:/models
|
||||
- backends:/backends
|
||||
- images:/tmp/generated/images
|
||||
|
||||
localrecall-postgres:
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=localrecall
|
||||
- POSTGRES_USER=localrecall
|
||||
- POSTGRES_PASSWORD=localrecall
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
localrecall:
|
||||
image: quay.io/mudler/localrecall:main
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.4}
|
||||
depends_on:
|
||||
localrecall-postgres:
|
||||
condition: service_healthy
|
||||
localai:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 8080
|
||||
environment:
|
||||
- COLLECTION_DB_PATH=/db
|
||||
- DATABASE_URL=postgresql://localrecall:localrecall@localrecall-postgres:5432/localrecall?sslmode=disable
|
||||
- VECTOR_ENGINE=postgres
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- FILE_ASSETS=/assets
|
||||
- OPENAI_API_KEY=sk-1234567890
|
||||
- OPENAI_BASE_URL=http://localai:8080
|
||||
- HYBRID_SEARCH_BM25_WEIGHT=0.5
|
||||
- HYBRID_SEARCH_VECTOR_WEIGHT=0.5
|
||||
volumes:
|
||||
- ./volumes/localrag/db:/db
|
||||
- ./volumes/localrag/assets/:/assets
|
||||
- localrag_assets:/assets
|
||||
|
||||
localrecall-healthcheck:
|
||||
depends_on:
|
||||
@@ -61,30 +83,14 @@ 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
|
||||
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "-H", "unix:///var/run/docker.sock"]
|
||||
environment:
|
||||
- DOCKER_TLS_CERTDIR=""
|
||||
expose:
|
||||
- 2375
|
||||
healthcheck:
|
||||
test: ["CMD", "docker", "info"]
|
||||
interval: 10s
|
||||
@@ -97,7 +103,7 @@ services:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
mcpbox:
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: .
|
||||
@@ -108,16 +114,23 @@ services:
|
||||
environment:
|
||||
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- LOCALAGI_LLM_API_URL=http://localai:8080
|
||||
#- LOCALAGI_LLM_API_KEY=sk-1234567890
|
||||
- LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
- 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:
|
||||
- ./volumes/localagi/:/pool
|
||||
- localagi_pool:/pool
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
models:
|
||||
backends:
|
||||
images:
|
||||
localrag_assets:
|
||||
localagi_pool:
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
module github.com/mudler/LocalAGI
|
||||
|
||||
go 1.24
|
||||
|
||||
toolchain go1.24.2
|
||||
go 1.24.4
|
||||
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/blevesearch/bleve/v2 v2.5.7
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/chasefleming/elem-go v0.30.0
|
||||
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
|
||||
github.com/donseba/go-htmx v1.12.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10
|
||||
github.com/go-telegram/bot v1.17.0
|
||||
github.com/gofiber/fiber/v2 v2.52.8
|
||||
github.com/gofiber/template/html/v2 v2.1.3
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
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/jung-kurt/gofpdf v1.16.2
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9
|
||||
github.com/mudler/xlog v0.0.1
|
||||
github.com/onsi/ginkgo/v2 v2.25.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/philippgille/chromem-go v0.7.0
|
||||
@@ -24,73 +25,86 @@ require (
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
|
||||
github.com/tmc/langchaingo v0.1.13
|
||||
github.com/tmc/langchaingo v0.1.14
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
github.com/valyala/fasthttp v1.62.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
|
||||
maunium.net/go/mautrix v0.17.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
github.com/JohannesKaufmann/dom v0.2.0 // indirect
|
||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.22.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
|
||||
github.com/blevesearch/geo v0.2.4 // indirect
|
||||
github.com/blevesearch/go-faiss v1.0.26 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.1.0 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
go.etcd.io/bbolt v1.4.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0
|
||||
github.com/PuerkitoBio/goquery v1.10.3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
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
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||
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/klauspost/compress v1.18.1 // 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
|
||||
@@ -101,21 +115,16 @@ 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
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.37.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
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ=
|
||||
github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr5LDNANsqunyI/btpyfCg8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0/go.mod h1:OLaKh+giepO8j7teevrNwiy/fwf8LXgoc9g7rwaE1jk=
|
||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
|
||||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
|
||||
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
|
||||
@@ -17,32 +29,84 @@ 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/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
|
||||
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
|
||||
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
|
||||
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
|
||||
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
|
||||
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
|
||||
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
|
||||
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
|
||||
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
|
||||
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
|
||||
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
|
||||
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
|
||||
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
|
||||
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
|
||||
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
|
||||
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
|
||||
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
|
||||
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
|
||||
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
|
||||
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
|
||||
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
|
||||
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
|
||||
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
|
||||
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
|
||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
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/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
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=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE=
|
||||
github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s=
|
||||
github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw=
|
||||
github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
@@ -53,22 +117,16 @@ 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
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,19 +135,13 @@ 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=
|
||||
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
|
||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||
github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
|
||||
github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
|
||||
github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
|
||||
github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
@@ -97,6 +149,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
@@ -109,6 +163,8 @@ github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMM
|
||||
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 +172,25 @@ 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/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
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/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
|
||||
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
|
||||
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
|
||||
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/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
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/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
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,29 +201,74 @@ 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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
|
||||
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
||||
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b h1:LXHovZzNgP0n/oYEoO4zDt4k4CRvG0Owhu8x/OVGhYc=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44 h1:joGszpItINnZdoL/0p2077Wz2xnxMGRSRgYN5mS7I4c=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561 h1:qA7dGJhF5GjgGKHh0lOITZjl9q2jehjKqxxCnaUR1yg=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc h1:tBAGwQq5kOSIh+vfLffVr5Th2ajFwrTj0usLgyGM2CQ=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216144443-c96e8ddc1157 h1:M2Yx84QtKQrhMWlB+K99RKJ+x5s8HoRumegObaLuQlI=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216144443-c96e8ddc1157/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216163119-07b624cc772f h1:LkCJD11Mlx8ZyHoNRbpxbrw1Znl9GiFKjZbcXYwAKwk=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216163119-07b624cc772f/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9 h1:Ek+fAIUt9v5tqBgPZ89QTgGXwOSZcCiElD37NkbdmSk=
|
||||
github.com/mudler/cogito v0.9.1-0.20260216182842-e9820e6bf7b9/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
|
||||
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
|
||||
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
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/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -181,6 +281,7 @@ github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
|
||||
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
|
||||
@@ -189,22 +290,30 @@ github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5
|
||||
github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc=
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw=
|
||||
github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w=
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4=
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -217,35 +326,47 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tmc/langchaingo v0.1.13 h1:rcpMWBIi2y3B90XxfE4Ao8dhCQPVDMaNPnN5cGB1CaA=
|
||||
github.com/tmc/langchaingo v0.1.13/go.mod h1:vpQ5NOIhpzxDfTZK9B6tf2GM/MoaHewPWM5KXXGh7hg=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc=
|
||||
github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378=
|
||||
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/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
|
||||
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
|
||||
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=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
||||
go.mau.fi/util v0.3.0 h1:Lt3lbRXP6ZBqTINK0EieRWor3zEwwwrDT14Z5N8RUCs=
|
||||
go.mau.fi/util v0.3.0/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb h1:zOg9DxxrorEmgGUr5UPdCEwKqiqG0MlZciuCuA3XiDE=
|
||||
go.starlark.net v0.0.0-20250417143717-f57e51f710eb/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
|
||||
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=
|
||||
@@ -253,10 +374,11 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
|
||||
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
|
||||
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
|
||||
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -272,8 +394,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -281,6 +405,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -295,8 +421,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -306,8 +432,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -319,30 +445,31 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
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.0/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 +480,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")
|
||||
@@ -20,10 +23,7 @@ var stateDir = os.Getenv("LOCALAGI_STATE_DIR")
|
||||
var localRAG = os.Getenv("LOCALAGI_LOCALRAG_URL")
|
||||
var withLogs = os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true"
|
||||
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,18 +60,17 @@ func main() {
|
||||
pool, err := state.NewAgentPool(
|
||||
baseModel,
|
||||
multimodalModel,
|
||||
imageModel,
|
||||
transcriptionModel,
|
||||
transcriptionLanguage,
|
||||
ttsModel,
|
||||
apiURL,
|
||||
apiKey,
|
||||
stateDir,
|
||||
mcpboxURL,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts(map[string]string{
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
|
||||
// AgentConfig represents the configuration for an agent
|
||||
type AgentConfig struct {
|
||||
Name string `json:"name"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Connectors []string `json:"connectors,omitempty"`
|
||||
PromptBlocks []string `json:"prompt_blocks,omitempty"`
|
||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||
Parallel bool `json:"parallel,omitempty"`
|
||||
Config map[string]interface{} `json:"config,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Connectors []string `json:"connectors,omitempty"`
|
||||
PromptBlocks []string `json:"prompt_blocks,omitempty"`
|
||||
InitialPrompt string `json:"initial_prompt,omitempty"`
|
||||
Parallel bool `json:"parallel,omitempty"`
|
||||
EnableReasoning bool `json:"enable_reasoning,omitempty"`
|
||||
}
|
||||
|
||||
// AgentStatus represents the status of an agent
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package localoperator
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string, timeout ...time.Duration) *Client {
|
||||
defaultTimeout := 30 * time.Second
|
||||
if len(timeout) > 0 {
|
||||
defaultTimeout = timeout[0]
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: defaultTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type AgentRequest struct {
|
||||
Goal string `json:"goal"`
|
||||
MaxAttempts int `json:"max_attempts,omitempty"`
|
||||
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopAgentRequest struct {
|
||||
AgentRequest
|
||||
DesktopURL string `json:"desktop_url"`
|
||||
}
|
||||
|
||||
type DeepResearchRequest struct {
|
||||
Topic string `json:"topic"`
|
||||
MaxCycles int `json:"max_cycles,omitempty"`
|
||||
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
|
||||
MaxResults int `json:"max_results,omitempty"`
|
||||
}
|
||||
|
||||
// Response types
|
||||
type StateDescription struct {
|
||||
CurrentURL string `json:"current_url"`
|
||||
PageTitle string `json:"page_title"`
|
||||
PageContentDescription string `json:"page_content_description"`
|
||||
Screenshot string `json:"screenshot"`
|
||||
ScreenshotMimeType string `json:"screenshot_mime_type"`
|
||||
}
|
||||
|
||||
type StateHistory struct {
|
||||
States []StateDescription `json:"states"`
|
||||
}
|
||||
|
||||
type DesktopStateDescription struct {
|
||||
ScreenContent string `json:"screen_content"`
|
||||
ScreenshotPath string `json:"screenshot_path"`
|
||||
}
|
||||
|
||||
type DesktopStateHistory struct {
|
||||
States []DesktopStateDescription `json:"states"`
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type ResearchResult struct {
|
||||
Topic string `json:"topic"`
|
||||
Summary string `json:"summary"`
|
||||
Sources []SearchResult `json:"sources"`
|
||||
KnowledgeGaps []string `json:"knowledge_gaps"`
|
||||
SearchQueries []string `json:"search_queries"`
|
||||
ResearchCycles int `json:"research_cycles"`
|
||||
CompletionTime time.Duration `json:"completion_time"`
|
||||
}
|
||||
|
||||
func (c *Client) RunBrowserAgent(req AgentRequest) (*StateHistory, error) {
|
||||
return post[*StateHistory](c.httpClient, c.baseURL+"/api/browser/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) RunDesktopAgent(req DesktopAgentRequest) (*DesktopStateHistory, error) {
|
||||
return post[*DesktopStateHistory](c.httpClient, c.baseURL+"/api/desktop/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) RunDeepResearch(req DeepResearchRequest) (*ResearchResult, error) {
|
||||
return post[*ResearchResult](c.httpClient, c.baseURL+"/api/deep-research/run", req)
|
||||
}
|
||||
|
||||
func (c *Client) Readyz() (string, error) {
|
||||
return c.get("/readyz")
|
||||
}
|
||||
|
||||
func (c *Client) Healthz() (string, error) {
|
||||
return c.get("/healthz")
|
||||
}
|
||||
|
||||
func (c *Client) get(path string) (string, error) {
|
||||
resp, err := c.httpClient.Get(c.baseURL + path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func post[T any](client *http.Client, url string, body interface{}) (T, error) {
|
||||
var result T
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Sending request", "url", url, "body", string(jsonBody))
|
||||
|
||||
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
fmt.Println("Response", "status", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return result, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
+179
-50
@@ -11,12 +11,14 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
var _ agent.RAGDB = &WrappedClient{}
|
||||
@@ -26,7 +28,8 @@ type WrappedClient struct {
|
||||
collection string
|
||||
}
|
||||
|
||||
func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
|
||||
func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
|
||||
collection := strings.TrimSpace(strings.ToLower(c))
|
||||
wc := &WrappedClient{
|
||||
Client: NewClient(baseURL, apiKey),
|
||||
collection: collection,
|
||||
@@ -37,6 +40,11 @@ func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
|
||||
return wc
|
||||
}
|
||||
|
||||
// Collection returns the collection name for this client.
|
||||
func (c *WrappedClient) Collection() string {
|
||||
return c.collection
|
||||
}
|
||||
|
||||
func (c *WrappedClient) Count() int {
|
||||
entries, err := c.ListEntries(c.collection)
|
||||
if err != nil {
|
||||
@@ -88,6 +96,37 @@ func (c *WrappedClient) Store(s string) error {
|
||||
return c.Client.Store(c.collection, f)
|
||||
}
|
||||
|
||||
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
|
||||
func (c *WrappedClient) GetEntryContent(entry string) (content string, chunkCount int, err error) {
|
||||
return c.Client.GetEntryContent(c.collection, entry)
|
||||
}
|
||||
|
||||
// apiResponse is the standardized LocalRecall API response wrapper (since 3f73ff3a).
|
||||
type apiResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error *apiError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// parseAPIError reads the response body and returns an error from the API response or a generic message.
|
||||
func parseAPIError(resp *http.Response, body []byte, fallback string) error {
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Error != nil {
|
||||
if wrap.Error.Details != "" {
|
||||
return fmt.Errorf("%s: %s", wrap.Error.Message, wrap.Error.Details)
|
||||
}
|
||||
return errors.New(wrap.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("%s: %s", fallback, string(body))
|
||||
}
|
||||
|
||||
// Result represents a single result from a query.
|
||||
type Result struct {
|
||||
ID string
|
||||
@@ -101,6 +140,13 @@ type Result struct {
|
||||
Similarity float32
|
||||
}
|
||||
|
||||
// EntryChunk represents a single chunk (legacy; GetEntryContent now returns full file content).
|
||||
type EntryChunk struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// Client is a client for the RAG API
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
@@ -151,7 +197,8 @@ func (c *Client) CreateCollection(name string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return errors.New("failed to create collection")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to create collection")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -174,17 +221,30 @@ func (c *Client) ListCollections() ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list collections")
|
||||
}
|
||||
|
||||
var collections []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&collections)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return collections, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list collections")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Collections []string `json:"collections"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Collections, nil
|
||||
}
|
||||
|
||||
// ListEntries lists all entries in a collection
|
||||
@@ -204,17 +264,75 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list entries")
|
||||
}
|
||||
|
||||
var entries []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&entries)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list entries")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Entries []string `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Entries, nil
|
||||
}
|
||||
|
||||
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
|
||||
func (c *Client) GetEntryContent(collection, entry string) (content string, chunkCount int, err error) {
|
||||
entryEscaped := url.PathEscape(entry)
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/entries/%s", c.BaseURL, collection, entryEscaped)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
c.addAuthHeader(req)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, parseAPIError(resp, body, "failed to get entry content")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return "", 0, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return "", 0, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Content string `json:"content"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return data.Content, data.ChunkCount, nil
|
||||
}
|
||||
|
||||
// DeleteEntry deletes an entry in a collection
|
||||
@@ -244,19 +362,30 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyResult := new(bytes.Buffer)
|
||||
bodyResult.ReadFrom(resp.Body)
|
||||
return nil, errors.New("failed to delete entry: " + bodyResult.String())
|
||||
}
|
||||
|
||||
var results []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&results)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to delete entry")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
RemainingEntries []string `json:"remaining_entries"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.RemainingEntries, nil
|
||||
}
|
||||
|
||||
// Search searches a collection
|
||||
@@ -287,17 +416,30 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to search collection")
|
||||
}
|
||||
|
||||
var results []Result
|
||||
err = json.NewDecoder(resp.Body).Decode(&results)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to search collection")
|
||||
}
|
||||
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Results, nil
|
||||
}
|
||||
|
||||
// Reset resets a collection
|
||||
@@ -318,9 +460,8 @@ func (c *Client) Reset(collection string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
return errors.New("failed to reset collection: " + b.String())
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to reset collection")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -369,20 +510,8 @@ func (c *Client) Store(collection, filePath string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
|
||||
type response struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var r response
|
||||
err = json.Unmarshal(b.Bytes(), &r)
|
||||
if err == nil {
|
||||
return errors.New("failed to upload file: " + r.Error)
|
||||
}
|
||||
|
||||
return errors.New("failed to upload file")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to upload file")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package xlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
var logger *slog.Logger
|
||||
|
||||
func init() {
|
||||
var level = slog.LevelDebug
|
||||
|
||||
switch os.Getenv("LOG_LEVEL") {
|
||||
case "info":
|
||||
level = slog.LevelInfo
|
||||
case "warn":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
var opts = &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
|
||||
if os.Getenv("LOG_FORMAT") == "json" {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
}
|
||||
logger = slog.New(handler)
|
||||
}
|
||||
|
||||
func _log(level slog.Level, msg string, args ...any) {
|
||||
_, f, l, _ := runtime.Caller(2)
|
||||
group := slog.Group(
|
||||
"source",
|
||||
slog.Attr{
|
||||
Key: "file",
|
||||
Value: slog.AnyValue(f),
|
||||
},
|
||||
slog.Attr{
|
||||
Key: "L",
|
||||
Value: slog.AnyValue(l),
|
||||
},
|
||||
)
|
||||
args = append(args, group)
|
||||
logger.Log(context.Background(), level, msg, args...)
|
||||
}
|
||||
|
||||
func Info(msg string, args ...any) {
|
||||
_log(slog.LevelInfo, msg, args...)
|
||||
}
|
||||
|
||||
func Debug(msg string, args ...any) {
|
||||
_log(slog.LevelDebug, msg, args...)
|
||||
}
|
||||
|
||||
func Error(msg string, args ...any) {
|
||||
_log(slog.LevelError, msg, args...)
|
||||
}
|
||||
|
||||
func Warn(msg string, args ...any) {
|
||||
_log(slog.LevelWarn, msg, args...)
|
||||
}
|
||||
+48
-32
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
)
|
||||
@@ -22,8 +22,6 @@ const (
|
||||
// Actions
|
||||
ActionSearch = "search"
|
||||
ActionCustom = "custom"
|
||||
ActionBrowserAgentRunner = "browser-agent-runner"
|
||||
ActionDeepResearchRunner = "deep-research-runner"
|
||||
ActionGithubIssueLabeler = "github-issue-labeler"
|
||||
ActionGithubIssueOpener = "github-issue-opener"
|
||||
ActionGithubIssueEditor = "github-issue-editor"
|
||||
@@ -47,16 +45,21 @@ const (
|
||||
ActionTwitterPost = "twitter-post"
|
||||
ActionSendMail = "send-mail"
|
||||
ActionGenerateImage = "generate_image"
|
||||
ActionGenerateSong = "generate_song"
|
||||
ActionGeneratePDF = "generate_pdf"
|
||||
ActionCounter = "counter"
|
||||
ActionCallAgents = "call_agents"
|
||||
ActionShellcommand = "shell-command"
|
||||
ActionSendTelegramMessage = "send-telegram-message"
|
||||
ActionSetReminder = "set_reminder"
|
||||
ActionSetRecurringReminder = "set_recurring_reminder"
|
||||
ActionSetOneTimeReminder = "set_onetime_reminder"
|
||||
ActionListReminders = "list_reminders"
|
||||
ActionRemoveReminder = "remove_reminder"
|
||||
ActionAddToMemory = "add_to_memory"
|
||||
ActionListMemory = "list_memory"
|
||||
ActionRemoveFromMemory = "remove_from_memory"
|
||||
ActionSearchMemory = "search_memory"
|
||||
ActionPiKVMPowerControl = "pikvm_power_control"
|
||||
ActionWebhook = "webhook"
|
||||
)
|
||||
@@ -79,8 +82,6 @@ var AvailableActions = []string{
|
||||
ActionGithubGetAllContent,
|
||||
ActionGithubRepositorySearchFiles,
|
||||
ActionGithubRepositoryListFiles,
|
||||
ActionBrowserAgentRunner,
|
||||
ActionDeepResearchRunner,
|
||||
ActionGithubRepositoryCreateOrUpdate,
|
||||
ActionGithubIssueReader,
|
||||
ActionGithubIssueCommenter,
|
||||
@@ -94,6 +95,8 @@ var AvailableActions = []string{
|
||||
ActionWikipedia,
|
||||
ActionSendMail,
|
||||
ActionGenerateImage,
|
||||
ActionGenerateSong,
|
||||
ActionGeneratePDF,
|
||||
ActionTwitterPost,
|
||||
ActionCounter,
|
||||
ActionCallAgents,
|
||||
@@ -105,6 +108,7 @@ var AvailableActions = []string{
|
||||
ActionAddToMemory,
|
||||
ActionListMemory,
|
||||
ActionRemoveFromMemory,
|
||||
ActionSearchMemory,
|
||||
ActionPiKVMPowerControl,
|
||||
ActionWebhook,
|
||||
}
|
||||
@@ -115,21 +119,21 @@ var DefaultActions = []config.FieldGroup{
|
||||
Label: "Search",
|
||||
Fields: actions.SearchConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "browser-agent-runner",
|
||||
Label: "Browser Agent Runner",
|
||||
Fields: actions.BrowserAgentRunnerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "deep-research-runner",
|
||||
Label: "Deep Research Runner",
|
||||
Fields: actions.DeepResearchRunnerConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_image",
|
||||
Label: "Generate Image",
|
||||
Fields: actions.GenImageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_song",
|
||||
Label: "Generate Song",
|
||||
Fields: actions.GenSongConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "generate_pdf",
|
||||
Label: "Generate PDF",
|
||||
Fields: actions.GenPDFConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "add_to_memory",
|
||||
Label: "Add to Memory",
|
||||
@@ -145,6 +149,11 @@ var DefaultActions = []config.FieldGroup{
|
||||
Label: "Remove from Memory",
|
||||
Fields: actions.RemoveFromMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "search_memory",
|
||||
Label: "Search Memory",
|
||||
Fields: actions.SearchMemoryConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "github-issue-labeler",
|
||||
Label: "GitHub Issue Labeler",
|
||||
@@ -281,8 +290,13 @@ var DefaultActions = []config.FieldGroup{
|
||||
Fields: actions.SendTelegramMessageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "set_reminder",
|
||||
Label: "Set Reminder",
|
||||
Name: "set_recurring_reminder",
|
||||
Label: "Set Recurring Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "set_onetime_reminder",
|
||||
Label: "Set One-Time Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
@@ -308,11 +322,9 @@ var DefaultActions = []config.FieldGroup{
|
||||
}
|
||||
|
||||
const (
|
||||
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
|
||||
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ConfigStateDir = "state-dir"
|
||||
CustomActionsDir = "custom-actions-dir"
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ConfigStateDir = "state-dir"
|
||||
CustomActionsDir = "custom-actions-dir"
|
||||
)
|
||||
|
||||
func customActions(customActionsDir string, existingActionConfigs map[string]map[string]string) (allActions []types.Action) {
|
||||
@@ -400,13 +412,17 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
memoryFilePath := memoryPath(agentName, actionsConfigs)
|
||||
memoryIdxPath := memoryIndexPath(agentName, actionsConfigs)
|
||||
|
||||
switch name {
|
||||
case ActionCustom:
|
||||
a, err = action.NewCustom(config, "")
|
||||
case ActionGenerateImage:
|
||||
a = actions.NewGenImage(config)
|
||||
case ActionGenerateSong:
|
||||
a = actions.NewGenSong(config)
|
||||
case ActionGeneratePDF:
|
||||
a = actions.NewGenPDF(config)
|
||||
case ActionSearch:
|
||||
a = actions.NewSearch(config)
|
||||
case ActionGithubIssueLabeler:
|
||||
@@ -419,10 +435,6 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
a = actions.NewGithubIssueCloser(config)
|
||||
case ActionGithubIssueSearcher:
|
||||
a = actions.NewGithubIssueSearch(config)
|
||||
case ActionBrowserAgentRunner:
|
||||
a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
|
||||
case ActionDeepResearchRunner:
|
||||
a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
|
||||
case ActionGithubIssueReader:
|
||||
a = actions.NewGithubIssueReader(config)
|
||||
case ActionGithubPRReader:
|
||||
@@ -467,18 +479,22 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
|
||||
case ActionSendTelegramMessage:
|
||||
a = actions.NewSendTelegramMessageRunner(config)
|
||||
case ActionSetReminder:
|
||||
a = action.NewReminder()
|
||||
case ActionSetRecurringReminder:
|
||||
a = action.NewRecurringReminder()
|
||||
case ActionSetOneTimeReminder:
|
||||
a = action.NewOneTimeReminder()
|
||||
case ActionListReminders:
|
||||
a = action.NewListReminders()
|
||||
case ActionRemoveReminder:
|
||||
a = action.NewRemoveReminder()
|
||||
case ActionAddToMemory:
|
||||
a, _, _ = actions.NewMemoryActions(memoryFilePath, config)
|
||||
a, _, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionListMemory:
|
||||
_, a, _ = actions.NewMemoryActions(memoryFilePath, config)
|
||||
_, a, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionRemoveFromMemory:
|
||||
_, _, a = actions.NewMemoryActions(memoryFilePath, config)
|
||||
_, _, a, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionSearchMemory:
|
||||
_, _, _, a = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionPiKVMPowerControl:
|
||||
a = actions.NewPiKVMAction(config)
|
||||
default:
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
api "github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataBrowserAgentHistory = "browser_agent_history"
|
||||
)
|
||||
|
||||
type BrowserAgentRunner struct {
|
||||
baseURL, customActionName string
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewBrowserAgentRunner(config map[string]string, defaultURL string) *BrowserAgentRunner {
|
||||
if config["baseURL"] == "" {
|
||||
config["baseURL"] = defaultURL
|
||||
}
|
||||
|
||||
timeout := "15m"
|
||||
if config["timeout"] != "" {
|
||||
timeout = config["timeout"]
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
// If parsing fails, use default 15 minutes
|
||||
duration = 15 * time.Minute
|
||||
}
|
||||
|
||||
client := api.NewClient(config["baseURL"], duration)
|
||||
|
||||
return &BrowserAgentRunner{
|
||||
client: client,
|
||||
baseURL: config["baseURL"],
|
||||
customActionName: config["customActionName"],
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BrowserAgentRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := api.AgentRequest{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
|
||||
}
|
||||
|
||||
req := api.AgentRequest{
|
||||
Goal: result.Goal,
|
||||
MaxAttempts: result.MaxAttempts,
|
||||
MaxNoActionAttempts: result.MaxNoActionAttempts,
|
||||
}
|
||||
|
||||
stateHistory, err := b.client.RunBrowserAgent(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to run browser agent: %w", err)
|
||||
}
|
||||
|
||||
// Format the state history into a readable string
|
||||
var historyStr string
|
||||
// for i, state := range stateHistory.States {
|
||||
// historyStr += fmt.Sprintf("State %d:\n", i+1)
|
||||
// historyStr += fmt.Sprintf(" URL: %s\n", state.CurrentURL)
|
||||
// historyStr += fmt.Sprintf(" Title: %s\n", state.PageTitle)
|
||||
// historyStr += fmt.Sprintf(" Description: %s\n\n", state.PageContentDescription)
|
||||
// }
|
||||
|
||||
historyStr += fmt.Sprintf(" URL: %s\n", stateHistory.States[len(stateHistory.States)-1].CurrentURL)
|
||||
historyStr += fmt.Sprintf(" Title: %s\n", stateHistory.States[len(stateHistory.States)-1].PageTitle)
|
||||
historyStr += fmt.Sprintf(" Description: %s\n\n", stateHistory.States[len(stateHistory.States)-1].PageContentDescription)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
|
||||
Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (b *BrowserAgentRunner) Definition() types.ActionDefinition {
|
||||
actionName := "run_browser_agent"
|
||||
if b.customActionName != "" {
|
||||
actionName = b.customActionName
|
||||
}
|
||||
description := "Run a browser agent to achieve a specific goal, for example: 'Go to https://www.google.com and search for 'LocalAI', and tell me what's on the first page'"
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(actionName),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"goal": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The goal for the browser agent to achieve",
|
||||
},
|
||||
"max_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts the agent can make (optional)",
|
||||
},
|
||||
"max_no_action_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts without taking an action (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"goal"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *BrowserAgentRunner) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// BrowserAgentRunnerConfigMeta returns the metadata for Browser Agent Runner action configuration fields
|
||||
func BrowserAgentRunnerConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "baseURL",
|
||||
Label: "Base URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Base URL of the LocalOperator API",
|
||||
},
|
||||
{
|
||||
Name: "customActionName",
|
||||
Label: "Custom Action Name",
|
||||
Type: config.FieldTypeText,
|
||||
HelpText: "Custom name for this action",
|
||||
},
|
||||
{
|
||||
Name: "timeout",
|
||||
Label: "Client Timeout",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
api "github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataDeepResearchResult = "deep_research_result"
|
||||
)
|
||||
|
||||
type DeepResearchRunner struct {
|
||||
baseURL, customActionName string
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func NewDeepResearchRunner(config map[string]string, defaultURL string) *DeepResearchRunner {
|
||||
if config["baseURL"] == "" {
|
||||
config["baseURL"] = defaultURL
|
||||
}
|
||||
|
||||
timeout := "15m"
|
||||
if config["timeout"] != "" {
|
||||
timeout = config["timeout"]
|
||||
}
|
||||
|
||||
duration, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
// If parsing fails, use default 15 minutes
|
||||
duration = 15 * time.Minute
|
||||
}
|
||||
|
||||
client := api.NewClient(config["baseURL"], duration)
|
||||
|
||||
return &DeepResearchRunner{
|
||||
client: client,
|
||||
baseURL: config["baseURL"],
|
||||
customActionName: config["customActionName"],
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := api.DeepResearchRequest{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
|
||||
}
|
||||
|
||||
req := api.DeepResearchRequest{
|
||||
Topic: result.Topic,
|
||||
MaxCycles: result.MaxCycles,
|
||||
MaxNoActionAttempts: result.MaxNoActionAttempts,
|
||||
MaxResults: result.MaxResults,
|
||||
}
|
||||
|
||||
researchResult, err := d.client.RunDeepResearch(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to run deep research: %w", err)
|
||||
}
|
||||
|
||||
// Format the research result into a readable string
|
||||
var resultStr string
|
||||
|
||||
resultStr += "Deep research result\n"
|
||||
resultStr += fmt.Sprintf("Topic: %s\n", researchResult.Topic)
|
||||
resultStr += fmt.Sprintf("Summary: %s\n", researchResult.Summary)
|
||||
resultStr += fmt.Sprintf("Research Cycles: %d\n", researchResult.ResearchCycles)
|
||||
resultStr += fmt.Sprintf("Completion Time: %s\n\n", researchResult.CompletionTime)
|
||||
|
||||
if len(researchResult.Sources) > 0 {
|
||||
resultStr += "Sources:\n"
|
||||
for _, source := range researchResult.Sources {
|
||||
resultStr += fmt.Sprintf("- %s (%s)\n %s\n", source.Title, source.URL, source.Description)
|
||||
}
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Deep research completed successfully.\n%s", resultStr),
|
||||
Metadata: map[string]interface{}{MetadataDeepResearchResult: researchResult},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Definition() types.ActionDefinition {
|
||||
actionName := "run_deep_research"
|
||||
if d.customActionName != "" {
|
||||
actionName = d.customActionName
|
||||
}
|
||||
description := "Run a deep research on a specific topic, gathering information from multiple sources and providing a comprehensive summary"
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(actionName),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"topic": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The topic to research",
|
||||
},
|
||||
"max_cycles": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of research cycles to perform (optional)",
|
||||
},
|
||||
"max_no_action_attempts": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of attempts without taking an action (optional)",
|
||||
},
|
||||
"max_results": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Maximum number of results to collect (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"topic"},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeepResearchRunner) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DeepResearchRunnerConfigMeta returns the metadata for Deep Research Runner action configuration fields
|
||||
func DeepResearchRunnerConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "baseURL",
|
||||
Label: "Base URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Base URL of the LocalOperator API",
|
||||
},
|
||||
{
|
||||
Name: "customActionName",
|
||||
Label: "Custom Action Name",
|
||||
Type: config.FieldTypeText,
|
||||
HelpText: "Custom name for this action",
|
||||
},
|
||||
{
|
||||
Name: "timeout",
|
||||
Label: "Client Timeout",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataPDFs = "pdf_paths"
|
||||
)
|
||||
|
||||
// NewGenPDF creates a new PDF generation action
|
||||
func NewGenPDF(config map[string]string) *GenPDFAction {
|
||||
a := &GenPDFAction{
|
||||
outputDir: config["outputDir"],
|
||||
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
|
||||
}
|
||||
|
||||
if a.outputDir != "" {
|
||||
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
|
||||
xlog.Error("Failed to create output directory", "path", a.outputDir, "error", err)
|
||||
}
|
||||
if a.cleanOnStart {
|
||||
entries, err := os.ReadDir(a.outputDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
type GenPDFAction struct {
|
||||
outputDir string
|
||||
cleanOnStart bool
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Filename string `json:"filename"`
|
||||
}{}
|
||||
if err := params.Unmarshal(&result); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if result.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("content is required")
|
||||
}
|
||||
|
||||
if a.outputDir == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_pdf (configure the action with an output directory)")
|
||||
}
|
||||
|
||||
// Generate filename if not provided
|
||||
filename := result.Filename
|
||||
if filename == "" {
|
||||
filename = fmt.Sprintf("document_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// Clean filename to prevent path traversal
|
||||
filename = filepath.Base(filename)
|
||||
|
||||
// Ensure filename has .pdf extension
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
|
||||
filename = filename + ".pdf"
|
||||
}
|
||||
|
||||
// Create PDF
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.AddPage()
|
||||
|
||||
// Add title if provided
|
||||
if result.Title != "" {
|
||||
pdf.SetFont("Arial", "B", 16)
|
||||
pdf.MultiCell(0, 10, result.Title, "", "", false)
|
||||
pdf.Ln(5)
|
||||
}
|
||||
|
||||
// Add content
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.MultiCell(0, 10, result.Content, "", "", false)
|
||||
|
||||
// Save PDF
|
||||
savedPath := filepath.Join(a.outputDir, filename)
|
||||
if err := pdf.OutputFileAndClose(savedPath); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to save PDF: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("PDF generated and saved to: %s", savedPath),
|
||||
Metadata: map[string]interface{}{
|
||||
MetadataPDFs: []string{savedPath},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "generate_pdf",
|
||||
Description: "Generate a PDF document from text content. The PDF is saved locally and can be sent to the user by connectors.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"title": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Title of the PDF document",
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Text content to include in the PDF document",
|
||||
},
|
||||
"filename": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Optional custom filename (extension is optional - .pdf will be automatically added if missing)",
|
||||
},
|
||||
},
|
||||
Required: []string{"content"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenPDFAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GenPDFConfigMeta returns the metadata for GenPDF action configuration fields.
|
||||
func GenPDFConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "outputDir",
|
||||
Label: "Output directory",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Directory where generated PDF files are saved",
|
||||
},
|
||||
{
|
||||
Name: "cleanOnStart",
|
||||
Label: "Clean output directory on start",
|
||||
Type: config.FieldTypeCheckbox,
|
||||
DefaultValue: false,
|
||||
HelpText: "If enabled, clear the output directory when the action is loaded",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package actions_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("GenPDFAction", func() {
|
||||
var (
|
||||
tmpDir string
|
||||
action *actions.GenPDFAction
|
||||
ctx context.Context
|
||||
sharedState *types.AgentSharedState
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "genpdf_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
action = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
})
|
||||
|
||||
ctx = context.Background()
|
||||
sharedState = &types.AgentSharedState{}
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
It("generates PDF with title and content", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"title": "Test Document",
|
||||
"content": "This is test content for the PDF.",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
Expect(result.Metadata).To(HaveKey(actions.MetadataPDFs))
|
||||
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("requires content parameter", func() {
|
||||
_, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"title": "Test",
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("content is required"))
|
||||
})
|
||||
|
||||
It("uses custom filename when provided", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "custom_name",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("custom_name.pdf"))
|
||||
})
|
||||
|
||||
It("generates PDF with content only (no title)", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Just some content without a title.",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("automatically adds .pdf extension if missing", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "my_document",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("my_document.pdf"))
|
||||
})
|
||||
|
||||
It("does not double-add .pdf extension", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "document.pdf",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(filepath.Base(paths[0])).To(Equal("document.pdf"))
|
||||
})
|
||||
|
||||
It("requires outputDir to be configured", func() {
|
||||
actionNoDir := actions.NewGenPDF(map[string]string{})
|
||||
_, err := actionNoDir.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
})
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("outputDir is required"))
|
||||
})
|
||||
|
||||
It("cleans output directory on start if cleanOnStart is enabled", func() {
|
||||
// Create a test file in the directory
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
|
||||
// Create a new action with cleanOnStart enabled
|
||||
_ = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
"cleanOnStart": "true",
|
||||
})
|
||||
|
||||
// The test file should be deleted
|
||||
Expect(testFile).ToNot(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("does not clean output directory if cleanOnStart is disabled", func() {
|
||||
// Create a test file in the directory
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
err := os.WriteFile(testFile, []byte("test"), 0644)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
|
||||
// Create a new action with cleanOnStart disabled (default)
|
||||
_ = actions.NewGenPDF(map[string]string{
|
||||
"outputDir": tmpDir,
|
||||
})
|
||||
|
||||
// The test file should still exist
|
||||
Expect(testFile).To(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("prevents path traversal in filename", func() {
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": "Test content",
|
||||
"filename": "../../../etc/passwd",
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
// Should only use the base filename, not the path
|
||||
Expect(filepath.Base(paths[0])).To(Equal("passwd.pdf"))
|
||||
// Should be in the tmpDir, not in /etc
|
||||
Expect(filepath.Dir(paths[0])).To(Equal(tmpDir))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const (
|
||||
MetadataSongs = "songs_paths"
|
||||
)
|
||||
|
||||
// audioExtensionFromContentType returns a file extension for common audio MIME types.
|
||||
// It strips parameters (e.g. "audio/flac; rate=44100" -> "flac").
|
||||
func audioExtensionFromContentType(contentType string) string {
|
||||
mediaType, _, _ := strings.Cut(strings.TrimSpace(contentType), ";")
|
||||
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
|
||||
switch mediaType {
|
||||
case "audio/flac":
|
||||
return "flac"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return "mp3"
|
||||
case "audio/wav", "audio/wave", "audio/x-wav":
|
||||
return "wav"
|
||||
case "audio/ogg":
|
||||
return "ogg"
|
||||
case "audio/webm":
|
||||
return "webm"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// audioExtensionFromTag uses github.com/dhowden/tag to identify format from audio bytes.
|
||||
// Identify works on raw audio (e.g. FLAC without vorbis comments) and returns FileType.
|
||||
func audioExtensionFromTag(data []byte) string {
|
||||
if len(data) < 11 {
|
||||
return ""
|
||||
}
|
||||
r := bytes.NewReader(data)
|
||||
_, fileType, err := tag.Identify(r)
|
||||
if err != nil || fileType == tag.UnknownFileType {
|
||||
return ""
|
||||
}
|
||||
switch fileType {
|
||||
case tag.FLAC:
|
||||
return "flac"
|
||||
case tag.MP3:
|
||||
return "mp3"
|
||||
case tag.OGG:
|
||||
return "ogg"
|
||||
case tag.M4A:
|
||||
return "m4a"
|
||||
case tag.M4B:
|
||||
return "m4b"
|
||||
case tag.M4P:
|
||||
return "m4p"
|
||||
case tag.ALAC:
|
||||
return "m4a"
|
||||
case tag.DSF:
|
||||
return "dsf"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// soundRequest matches LocalAI /sound endpoint (ACE-Step advanced mode) request body.
|
||||
// See: https://localai.io/features/text-to-audio/
|
||||
type soundRequest struct {
|
||||
Model string `json:"model"`
|
||||
Caption string `json:"caption"`
|
||||
Lyrics string `json:"lyrics,omitempty"`
|
||||
BPM *int `json:"bpm,omitempty"`
|
||||
Keyscale string `json:"keyscale,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
|
||||
}
|
||||
|
||||
func NewGenSong(config map[string]string) *GenSongAction {
|
||||
model := config["model"]
|
||||
if model == "" {
|
||||
model = "ace-step-turbo"
|
||||
}
|
||||
a := &GenSongAction{
|
||||
apiURL: strings.TrimSuffix(config["apiURL"], "/"),
|
||||
apiKey: config["apiKey"],
|
||||
outputDir: config["outputDir"],
|
||||
model: model,
|
||||
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
|
||||
}
|
||||
|
||||
if a.outputDir != "" {
|
||||
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
|
||||
// log but continue; Run will fail with a clear error when saving
|
||||
_ = err
|
||||
}
|
||||
if a.cleanOnStart {
|
||||
entries, err := os.ReadDir(a.outputDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
type GenSongAction struct {
|
||||
apiURL string
|
||||
apiKey string
|
||||
outputDir string
|
||||
model string
|
||||
cleanOnStart bool
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := struct {
|
||||
Caption string `json:"caption"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
BPM *int `json:"bpm"`
|
||||
Keyscale string `json:"keyscale"`
|
||||
Language string `json:"language"`
|
||||
Duration *float64 `json:"duration_seconds"`
|
||||
Model string `json:"model"`
|
||||
}{}
|
||||
if err := params.Unmarshal(&result); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if result.Caption == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("caption is required")
|
||||
}
|
||||
|
||||
if a.outputDir == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_song (configure the action with an output directory)")
|
||||
}
|
||||
|
||||
reqBody := soundRequest{
|
||||
Model: a.model,
|
||||
Caption: result.Caption,
|
||||
Lyrics: result.Lyrics,
|
||||
Keyscale: result.Keyscale,
|
||||
Language: result.Language,
|
||||
DurationSeconds: result.Duration,
|
||||
BPM: result.BPM,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
url := a.apiURL + "/v1/sound-generation"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if a.apiKey != "" {
|
||||
req.Header.Set("xi-api-key", a.apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return types.ActionResult{Result: "Failed to generate song: " + err.Error()}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg, _ := io.ReadAll(resp.Body)
|
||||
return types.ActionResult{}, fmt.Errorf("sound endpoint failed: %s: %s", resp.Status, string(msg))
|
||||
}
|
||||
|
||||
audioBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
if len(audioBytes) == 0 {
|
||||
return types.ActionResult{}, fmt.Errorf("no audio data returned")
|
||||
}
|
||||
|
||||
ext := audioExtensionFromContentType(resp.Header.Get("Content-Type"))
|
||||
if ext == "" {
|
||||
ext = audioExtensionFromTag(audioBytes)
|
||||
}
|
||||
if ext == "" {
|
||||
ext = "flac" // default when unknown (e.g. ACE-Step)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("song_%d.%s", time.Now().UnixNano(), ext)
|
||||
savedPath := filepath.Join(a.outputDir, filename)
|
||||
if err := os.WriteFile(savedPath, audioBytes, 0644); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to save song: %w", err)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("The song was generated and saved to: %s", savedPath),
|
||||
Metadata: map[string]interface{}{
|
||||
MetadataSongs: []string{savedPath},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
Name: "generate_song",
|
||||
Description: "Generate a song or music track using LocalAI /sound endpoint (ACE-Step advanced mode). Uses caption, optional lyrics, BPM, key scale, language and duration. The file is saved locally and can be sent to the user by connectors.",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"caption": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Description of the song or music to generate (e.g. 'A funky Japanese disco track').",
|
||||
},
|
||||
"lyrics": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Lyrics or structure (e.g. '[Verse 1]\\n...').",
|
||||
},
|
||||
"bpm": {
|
||||
Type: jsonschema.Integer,
|
||||
Description: "Beats per minute (e.g. 120). Optional.",
|
||||
},
|
||||
"keyscale": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Key and scale (e.g. 'Ab major'). Optional.",
|
||||
},
|
||||
"language": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Language code for vocals (e.g. 'ja', 'en'). Optional.",
|
||||
},
|
||||
"duration_seconds": {
|
||||
Type: jsonschema.Number,
|
||||
Description: "Duration of the generated audio in seconds (e.g. 225). Optional.",
|
||||
},
|
||||
"model": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Model name (e.g. ace-step-turbo). Optional; uses action config default if omitted.",
|
||||
},
|
||||
},
|
||||
Required: []string{"caption"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenSongAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// GenSongConfigMeta returns the metadata for GenSong action configuration fields.
|
||||
func GenSongConfigMeta() []config.Field {
|
||||
return []config.Field{
|
||||
{
|
||||
Name: "apiURL",
|
||||
Label: "API URL",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
DefaultValue: "http://localhost:8080",
|
||||
HelpText: "LocalAI base URL (e.g. http://localhost:8080) for /sound endpoint",
|
||||
},
|
||||
{
|
||||
Name: "model",
|
||||
Label: "Model",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
DefaultValue: "ace-step-turbo",
|
||||
HelpText: "Default model for sound generation (e.g. ace-step-turbo)",
|
||||
},
|
||||
{
|
||||
Name: "apiKey",
|
||||
Label: "API Key",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Optional API key if the endpoint requires authentication",
|
||||
},
|
||||
{
|
||||
Name: "outputDir",
|
||||
Label: "Output directory",
|
||||
Type: config.FieldTypeText,
|
||||
Required: true,
|
||||
HelpText: "Directory where generated song files are saved (required for connectors to send files)",
|
||||
},
|
||||
{
|
||||
Name: "cleanOnStart",
|
||||
Label: "Clean output directory on start",
|
||||
Type: config.FieldTypeCheckbox,
|
||||
DefaultValue: false,
|
||||
HelpText: "If enabled, clear the output directory when the action is loaded",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/google/go-github/v69/github"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
+284
-103
@@ -2,22 +2,29 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"io"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// Remove global const and mutex, and add them as fields to a struct
|
||||
// indexCache avoids opening the same Bleve index path multiple times, which would
|
||||
// deadlock (Bleve uses file locks; a second Open() on the same path blocks).
|
||||
var (
|
||||
indexCache = map[string]bleve.Index{}
|
||||
indexCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
type MemoryActions struct {
|
||||
filePath string
|
||||
index bleve.Index
|
||||
indexPath string
|
||||
customName string
|
||||
customDescription string
|
||||
}
|
||||
@@ -25,138 +32,269 @@ type MemoryActions struct {
|
||||
type AddToMemoryAction struct{ *MemoryActions }
|
||||
type ListMemoryAction struct{ *MemoryActions }
|
||||
type RemoveFromMemoryAction struct{ *MemoryActions }
|
||||
type SearchMemoryAction struct{ *MemoryActions }
|
||||
|
||||
// NewMemoryActions returns the three actions, using the provided filePath and config
|
||||
func NewMemoryActions(filePath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction) {
|
||||
ma := &MemoryActions{filePath: filePath}
|
||||
// MemoryEntry matches the MCP memory structure (Bleve-backed).
|
||||
type MemoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// NewMemoryActions returns the four memory actions (Add, List, Remove, Search) using a Bleve index at indexPath.
|
||||
func NewMemoryActions(indexPath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction, *SearchMemoryAction) {
|
||||
ma := &MemoryActions{indexPath: indexPath}
|
||||
if config != nil {
|
||||
ma.customName = config["custom_name"]
|
||||
ma.customDescription = config["custom_description"]
|
||||
}
|
||||
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}
|
||||
}
|
||||
|
||||
type addToMemoryParams struct {
|
||||
Item string `json:"item"`
|
||||
}
|
||||
|
||||
type removeFromMemoryParams struct {
|
||||
Index *int `json:"index,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func (m *MemoryActions) readMemory() ([]string, error) {
|
||||
f, err := os.Open(m.filePath)
|
||||
idx, err := openOrCreateBleveIndex(indexPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
// Allow lazy init: index will be nil and operations will return this error
|
||||
ma.index = nil
|
||||
} else {
|
||||
ma.index = idx
|
||||
}
|
||||
defer f.Close()
|
||||
var items []string
|
||||
if err := json.NewDecoder(f).Decode(&items); err != nil {
|
||||
if err == io.EOF {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}, &SearchMemoryAction{ma}
|
||||
}
|
||||
|
||||
func (m *MemoryActions) writeMemory(items []string) error {
|
||||
f, err := os.Create(m.filePath)
|
||||
func openOrCreateBleveIndex(indexPath string) (bleve.Index, error) {
|
||||
indexCacheMu.Lock()
|
||||
if idx, ok := indexCache[indexPath]; ok {
|
||||
indexCacheMu.Unlock()
|
||||
return idx, nil
|
||||
}
|
||||
indexCacheMu.Unlock()
|
||||
|
||||
var idx bleve.Index
|
||||
var err error
|
||||
if _, statErr := os.Stat(indexPath); statErr == nil {
|
||||
idx, err = bleve.Open(indexPath)
|
||||
} else {
|
||||
os.MkdirAll(filepath.Dir(indexPath), 0755)
|
||||
mapping := bleve.NewIndexMapping()
|
||||
entryMapping := bleve.NewDocumentMapping()
|
||||
|
||||
nameFieldMapping := bleve.NewTextFieldMapping()
|
||||
nameFieldMapping.Analyzer = "standard"
|
||||
nameFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("name", nameFieldMapping)
|
||||
|
||||
contentFieldMapping := bleve.NewTextFieldMapping()
|
||||
contentFieldMapping.Analyzer = "standard"
|
||||
contentFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("content", contentFieldMapping)
|
||||
|
||||
dateFieldMapping := bleve.NewDateTimeFieldMapping()
|
||||
dateFieldMapping.Store = true
|
||||
entryMapping.AddFieldMappingsAt("created_at", dateFieldMapping)
|
||||
|
||||
mapping.AddDocumentMapping("_default", entryMapping)
|
||||
idx, err = bleve.New(indexPath, mapping)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
indexCacheMu.Lock()
|
||||
indexCache[indexPath] = idx
|
||||
indexCacheMu.Unlock()
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func (m *MemoryActions) ensureIndex() error {
|
||||
if m.index != nil {
|
||||
return nil
|
||||
}
|
||||
idx, err := openOrCreateBleveIndex(m.indexPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewEncoder(f).Encode(items)
|
||||
m.index = idx
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type addToMemoryParams struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type removeFromMemoryParams struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type searchMemoryParams struct {
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req addToMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if req.Item == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("item cannot be empty")
|
||||
if req.Name == "" && req.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("name or content cannot both be empty")
|
||||
}
|
||||
items, err := a.readMemory()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
entry := MemoryEntry{
|
||||
ID: generateID(),
|
||||
Name: req.Name,
|
||||
Content: req.Content,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
items = append(items, req.Item)
|
||||
if err := a.writeMemory(items); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
if err := a.index.Index(entry.ID, entry); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to index memory entry: %w", err)
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Added item to memory: %s", req.Item),
|
||||
Metadata: map[string]any{"item": req.Item, "count": len(items)},
|
||||
Result: fmt.Sprintf("Added memory entry: id=%s name=%q", entry.ID, entry.Name),
|
||||
Metadata: map[string]any{"id": entry.ID, "name": entry.Name, "content": entry.Content, "created_at": entry.CreatedAt},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
items, err := a.readMemory()
|
||||
if err != nil {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
query := bleve.NewMatchAllQuery()
|
||||
searchRequest := bleve.NewSearchRequest(query)
|
||||
searchRequest.Size = 10000
|
||||
searchRequest.Fields = []string{"name", "created_at"}
|
||||
searchRequest.SortBy([]string{"-created_at"})
|
||||
|
||||
outputResult := "Number of items in memory: " + strconv.Itoa(len(items)) + "\n"
|
||||
for i, item := range items {
|
||||
outputResult += fmt.Sprintf("%d) %s\n", i, item)
|
||||
searchResult, err := a.index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
|
||||
}
|
||||
|
||||
type listEntry struct {
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
entries := make([]listEntry, 0, len(searchResult.Hits))
|
||||
for _, hit := range searchResult.Hits {
|
||||
e := listEntry{}
|
||||
if v, ok := hit.Fields["name"].(string); ok {
|
||||
e.Name = v
|
||||
}
|
||||
if v, ok := hit.Fields["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
e.CreatedAt = t
|
||||
}
|
||||
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
|
||||
e.CreatedAt = v
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
outputResult := "Number of items in memory: " + strconv.Itoa(len(entries)) + "\n"
|
||||
for i, e := range entries {
|
||||
createdStr := e.CreatedAt.Format(time.RFC3339)
|
||||
outputResult += fmt.Sprintf("%d) %s (created_at: %s)\n", i, e.Name, createdStr)
|
||||
}
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.Name
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: outputResult,
|
||||
Metadata: map[string]any{"items": items},
|
||||
Metadata: map[string]any{"names": names, "entries": entries, "count": len(entries)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *RemoveFromMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req removeFromMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
items, err := a.readMemory()
|
||||
if req.ID == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("id is required to remove a memory entry")
|
||||
}
|
||||
doc, err := a.index.Document(req.ID)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
return types.ActionResult{}, fmt.Errorf("failed to check document: %w", err)
|
||||
}
|
||||
var removed string
|
||||
if req.Index != nil {
|
||||
idx := *req.Index
|
||||
if idx < 0 || idx >= len(items) {
|
||||
return types.ActionResult{}, fmt.Errorf("index out of range")
|
||||
}
|
||||
removed = items[idx]
|
||||
items = append(items[:idx], items[idx+1:]...)
|
||||
} else if req.Value != "" {
|
||||
found := false
|
||||
for i, v := range items {
|
||||
if v == req.Value {
|
||||
removed = v
|
||||
items = append(items[:i], items[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return types.ActionResult{}, fmt.Errorf("value not found in memory")
|
||||
}
|
||||
} else {
|
||||
return types.ActionResult{}, fmt.Errorf("must provide index or value to remove")
|
||||
if doc == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("memory entry with ID %q not found", req.ID)
|
||||
}
|
||||
if err := a.writeMemory(items); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
if err := a.index.Delete(req.ID); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to delete memory entry: %w", err)
|
||||
}
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Removed item from memory: %s", removed),
|
||||
Metadata: map[string]any{"removed": removed, "count": len(items)},
|
||||
Result: fmt.Sprintf("Removed memory entry with ID %q", req.ID),
|
||||
Metadata: map[string]any{"removed_id": req.ID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *SearchMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
var req searchMemoryParams
|
||||
if err := params.Unmarshal(&req); err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
|
||||
}
|
||||
if req.Query == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
|
||||
}
|
||||
nameQuery := bleve.NewMatchQuery(req.Query)
|
||||
nameQuery.SetField("name")
|
||||
contentQuery := bleve.NewMatchQuery(req.Query)
|
||||
contentQuery.SetField("content")
|
||||
disjunctionQuery := bleve.NewDisjunctionQuery(nameQuery, contentQuery)
|
||||
|
||||
searchRequest := bleve.NewSearchRequest(disjunctionQuery)
|
||||
searchRequest.Size = 100
|
||||
searchRequest.Fields = []string{"name", "content", "created_at"}
|
||||
|
||||
searchResult, err := a.index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
|
||||
}
|
||||
|
||||
results := make([]MemoryEntry, 0, len(searchResult.Hits))
|
||||
for _, hit := range searchResult.Hits {
|
||||
e := MemoryEntry{ID: hit.ID}
|
||||
if v, ok := hit.Fields["name"].(string); ok {
|
||||
e.Name = v
|
||||
}
|
||||
if v, ok := hit.Fields["content"].(string); ok {
|
||||
e.Content = v
|
||||
}
|
||||
if v, ok := hit.Fields["created_at"].(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
e.CreatedAt = t
|
||||
}
|
||||
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
|
||||
e.CreatedAt = v
|
||||
}
|
||||
results = append(results, e)
|
||||
}
|
||||
|
||||
outputResult := fmt.Sprintf("Query: %q — %d result(s)\n", req.Query, len(results))
|
||||
for i, e := range results {
|
||||
outputResult += fmt.Sprintf("%d) [%s] %s — %s\n", i, e.ID, e.Name, e.Content)
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: outputResult,
|
||||
Metadata: map[string]any{"query": req.Query, "results": results, "count": len(results)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "add_to_memory"
|
||||
description := "Add a string item to memory (stored in a JSON file)."
|
||||
description := "Add a new entry to memory storage (name and/or content). Stored in a Bleve index."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -167,18 +305,22 @@ func (a *AddToMemoryAction) Definition() types.ActionDefinition {
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"item": {
|
||||
"name": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The string item to add to memory.",
|
||||
Description: "The name/title of the memory entry.",
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The content to store in memory.",
|
||||
},
|
||||
},
|
||||
Required: []string{"item"},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "list_memory"
|
||||
description := "List all items currently stored in memory."
|
||||
description := "List all memory entry names."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -195,7 +337,7 @@ func (a *ListMemoryAction) Definition() types.ActionDefinition {
|
||||
|
||||
func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "remove_from_memory"
|
||||
description := "Remove an item from memory by index or value."
|
||||
description := "Remove a memory entry by ID."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -206,22 +348,41 @@ func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"index": {
|
||||
Type: jsonschema.Integer,
|
||||
Description: "The index of the item to remove (optional, 0-based)",
|
||||
},
|
||||
"value": {
|
||||
"id": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The value of the item to remove (optional)",
|
||||
Description: "The ID of the memory entry to remove.",
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
Required: []string{"id"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Plannable() bool { return true }
|
||||
func (a *ListMemoryAction) Plannable() bool { return true }
|
||||
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
|
||||
func (a *SearchMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "search_memory"
|
||||
description := "Search memory entries by name and content using full-text search."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
if a.customDescription != "" {
|
||||
description = a.customDescription
|
||||
}
|
||||
return types.ActionDefinition{
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"query": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The search query to find matching memory entries.",
|
||||
},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Plannable() bool { return true }
|
||||
func (a *ListMemoryAction) Plannable() bool { return true }
|
||||
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
|
||||
func (a *SearchMemoryAction) Plannable() bool { return true }
|
||||
|
||||
// AddToMemoryConfigMeta returns the metadata for AddToMemory action configuration fields
|
||||
func AddToMemoryConfigMeta() []config.Field {
|
||||
@@ -238,7 +399,7 @@ func AddToMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional, defaults to 'Add a string item to memory (stored in a JSON file).')",
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -258,7 +419,7 @@ func ListMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional, defaults to 'List all items currently stored in memory.')",
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -278,7 +439,27 @@ func RemoveFromMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional, defaults to 'Remove an item from memory by index or value.')",
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SearchMemoryConfigMeta returns the metadata for SearchMemory action configuration fields
|
||||
func SearchMemoryConfigMeta() []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 'search_memory')",
|
||||
},
|
||||
{
|
||||
Name: "custom_description",
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package actions_test
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
@@ -12,72 +13,79 @@ import (
|
||||
|
||||
var _ = Describe("MemoryActions", func() {
|
||||
var (
|
||||
tmpFile string
|
||||
tmpDir string
|
||||
indexPath string
|
||||
aAdd *actions.AddToMemoryAction
|
||||
aList *actions.ListMemoryAction
|
||||
aRemove *actions.RemoveFromMemoryAction
|
||||
aSearch *actions.SearchMemoryAction
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
f, err := os.CreateTemp("", "memory_test_*.json")
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "memory_test_*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
tmpFile = f.Name()
|
||||
f.Close()
|
||||
aAdd, aList, aRemove = actions.NewMemoryActions(tmpFile, map[string]string{})
|
||||
indexPath = filepath.Join(tmpDir, "memory.bleve")
|
||||
aAdd, aList, aRemove, aSearch = actions.NewMemoryActions(indexPath, map[string]string{})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.Remove(tmpFile)
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
It("adds and lists items", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
It("adds and lists entries by name", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
|
||||
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Metadata["items"]).To(ContainElements("foo", "bar"))
|
||||
Expect(res.Metadata["names"]).To(ContainElements("foo", "baz"))
|
||||
Expect(res.Metadata["count"]).To(Equal(2))
|
||||
})
|
||||
|
||||
It("removes by index", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 0})
|
||||
It("removes by id", func() {
|
||||
addRes, _ := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
id, ok := addRes.Metadata["id"].(string)
|
||||
Expect(ok).To(BeTrue())
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": id})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(res.Metadata["items"]).To(ConsistOf("bar"))
|
||||
Expect(res.Metadata["names"]).To(ConsistOf("baz"))
|
||||
})
|
||||
|
||||
It("removes by value", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(res.Metadata["items"]).To(ConsistOf("foo"))
|
||||
})
|
||||
|
||||
It("returns error for out of range index", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 2})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for value not found", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for empty item", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error if neither index nor value provided", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
It("returns error for missing id on remove", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for unknown id on remove", func() {
|
||||
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": "nonexistent"})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for empty name and content on add", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "", "content": ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("search returns matching entries", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "meeting", "content": "discussed project X"})
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "lunch", "content": "ate pizza"})
|
||||
res, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": "project"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Metadata["count"]).To(Equal(1))
|
||||
results, ok := res.Metadata["results"].([]actions.MemoryEntry)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(results).To(HaveLen(1))
|
||||
Expect(results[0].Name).To(Equal("meeting"))
|
||||
Expect(results[0].Content).To(Equal("discussed project X"))
|
||||
})
|
||||
|
||||
It("search returns error for empty query", func() {
|
||||
_, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": ""})
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
+23
-1
@@ -3,8 +3,9 @@ package services
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
func memoryPath(agentName string, actionsConfigs map[string]string) string {
|
||||
@@ -26,3 +27,24 @@ func memoryPath(agentName string, actionsConfigs map[string]string) string {
|
||||
|
||||
return memoryFilePath
|
||||
}
|
||||
|
||||
// memoryIndexPath returns the directory path for the Bleve index (used by memory actions).
|
||||
func memoryIndexPath(agentName string, actionsConfigs map[string]string) string {
|
||||
indexPath := "memory.bleve"
|
||||
if actionsConfigs != nil {
|
||||
if stateDir, ok := actionsConfigs[ConfigStateDir]; ok && stateDir != "" {
|
||||
memoryDir := fmt.Sprintf("%s/memory", stateDir)
|
||||
if err := os.MkdirAll(memoryDir, 0755); err != nil {
|
||||
xlog.Error("Error creating memory directory", "error", err)
|
||||
return indexPath
|
||||
}
|
||||
indexPath = filepath.Join(memoryDir, agentName+".bleve")
|
||||
} else {
|
||||
indexPath = agentName + ".memory.bleve"
|
||||
}
|
||||
}
|
||||
if dir := filepath.Dir(indexPath); dir != "." {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/connectors"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -85,11 +85,11 @@ func (d *Discord) Start(a *agent.Agent) {
|
||||
|
||||
if d.defaultChannel != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Message.Content)
|
||||
|
||||
// Send the message to the default channel
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Content)
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Message.Content)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error sending message: %v", err))
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func (d *Discord) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("discord:%s", d.defaultChannel),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -377,14 +377,14 @@ func (e *Email) Start(a *agent.Agent) {
|
||||
go func() {
|
||||
if e.defaultEmail != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Message.Content)
|
||||
|
||||
// Send the message to the default email
|
||||
e.sendMail(
|
||||
e.defaultEmail,
|
||||
"Message from LocalAGI",
|
||||
ccm.Content,
|
||||
ccm.Message.Content,
|
||||
"",
|
||||
"",
|
||||
[]string{e.defaultEmail},
|
||||
@@ -394,7 +394,7 @@ func (e *Email) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("email:%s", e.defaultEmail),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
irc "github.com/thoj/go-ircevent"
|
||||
)
|
||||
@@ -73,12 +73,12 @@ func (i *IRC) Start(a *agent.Agent) {
|
||||
|
||||
if i.channel != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Message.Content)
|
||||
|
||||
// Split the response into multiple messages if it's too long
|
||||
maxLength := 400 // Safe limit for most IRC servers
|
||||
response := ccm.Content
|
||||
response := ccm.Message.Content
|
||||
|
||||
// Handle multiline responses
|
||||
lines := strings.Split(response, "\n")
|
||||
@@ -109,7 +109,7 @@ func (i *IRC) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("irc:%s", i.channel),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
@@ -225,16 +225,16 @@ func (m *Matrix) Start(a *agent.Agent) {
|
||||
|
||||
if m.roomID != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(matrix)", "message", ccm.Content)
|
||||
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(matrix)", "message", ccm.Message.Content)
|
||||
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Message.Content)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("matrix:%s", m.roomID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
@@ -152,34 +152,34 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
||||
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
|
||||
for _, state := range j.State {
|
||||
// coming from the browser agent
|
||||
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// Decode base64 screenshot and upload to Slack
|
||||
if state.Screenshot != "" {
|
||||
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||
continue
|
||||
}
|
||||
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
// state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// // Decode base64 screenshot and upload to Slack
|
||||
// if state.Screenshot != "" {
|
||||
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
// if err != nil {
|
||||
// xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||
// continue
|
||||
// }
|
||||
|
||||
data := string(screenshotData)
|
||||
// Upload the file to Slack
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(screenshotData),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: "screenshot.png",
|
||||
InitialComment: "Browser Agent Screenshot",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// data := string(screenshotData)
|
||||
// // Upload the file to Slack
|
||||
// _, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
// Reader: bytes.NewReader(screenshotData),
|
||||
// FileSize: len(data),
|
||||
// ThreadTimestamp: ts,
|
||||
// Channel: channelID,
|
||||
// Filename: "screenshot.png",
|
||||
// InitialComment: "Browser Agent Screenshot",
|
||||
// })
|
||||
// if err != nil {
|
||||
// xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
|
||||
// continue
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// coming from the search action
|
||||
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
|
||||
@@ -204,6 +204,59 @@ func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, c
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the generate_song action (local file paths)
|
||||
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error reading song file %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
InitialComment: "Generated song",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading song to Slack: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error reading PDF file %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: ts,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
InitialComment: "Generated PDF document",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error uploading PDF to Slack: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -735,12 +788,12 @@ func (t *Slack) Start(a *agent.Agent) {
|
||||
if t.channelID != "" {
|
||||
xlog.Debug(fmt.Sprintf("Listening for messages in channel %s", t.channelID))
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
|
||||
_, _, err := api.PostMessage(t.channelID,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(ccm.Content, true),
|
||||
slack.MsgOptionText(ccm.Message.Content, true),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -749,7 +802,7 @@ func (t *Slack) Start(a *agent.Agent) {
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("slack:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+364
-72
@@ -10,7 +10,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -20,10 +22,9 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/localoperator"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -56,14 +57,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 +88,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 +265,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 +345,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 +518,48 @@ 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
|
||||
}
|
||||
|
||||
// sendSongToTelegram reads a song file from path and sends it to Telegram as audio.
|
||||
func sendSongToTelegram(ctx context.Context, b *bot.Bot, chatID int64, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading song file: %w", err)
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = b.SendAudio(ctx, &bot.SendAudioParams{
|
||||
ChatID: chatID,
|
||||
Audio: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated song",
|
||||
})
|
||||
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
|
||||
@@ -416,33 +580,72 @@ func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, re
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser agent screenshots
|
||||
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
state := historyStruct.States[len(historyStruct.States)-1]
|
||||
if state.Screenshot != "" {
|
||||
// Decode base64 screenshot
|
||||
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
if err != nil {
|
||||
xlog.Error("Error decoding screenshot", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Send screenshot with caption
|
||||
_, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: &models.InputFileUpload{
|
||||
Filename: "screenshot.png",
|
||||
Data: bytes.NewReader(screenshotData),
|
||||
},
|
||||
Caption: "Browser Agent Screenshot",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending screenshot", "error", err)
|
||||
}
|
||||
// Handle songs from generate_song action (local file paths)
|
||||
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
xlog.Debug("Sending song", "path", path)
|
||||
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
|
||||
xlog.Error("Error sending song", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PDFs from generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading PDF file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
xlog.Debug("Sending PDF document", "filename", filename, "size", len(data))
|
||||
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated PDF",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending PDF", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle browser agent screenshots
|
||||
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
// state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// if state.Screenshot != "" {
|
||||
// // Decode base64 screenshot
|
||||
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
// if err != nil {
|
||||
// xlog.Error("Error decoding screenshot", "error", err)
|
||||
// continue
|
||||
// }
|
||||
|
||||
// // Send screenshot with caption
|
||||
// _, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
// ChatID: chatID,
|
||||
// Photo: &models.InputFileUpload{
|
||||
// Filename: "screenshot.png",
|
||||
// Data: bytes.NewReader(screenshotData),
|
||||
// },
|
||||
// Caption: "Browser Agent Screenshot",
|
||||
// })
|
||||
// if err != nil {
|
||||
// xlog.Error("Error sending screenshot", "error", err)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
return urls, nil
|
||||
@@ -540,6 +743,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 +811,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)
|
||||
|
||||
@@ -684,11 +919,68 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
|
||||
if t.channelID != "" {
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(telegram)", "message", ccm.Content)
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(telegram)", "message", ccm.Message.Content)
|
||||
|
||||
// First, handle any multimedia content from metadata
|
||||
if ccm.Metadata != nil {
|
||||
// Handle images from gen image actions
|
||||
if imagesUrls, exists := ccm.Metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
|
||||
xlog.Debug("Sending photo from new conversation", "url", url)
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
if err := sendImageToTelegram(ctx, t.bot, chatID, url); err != nil {
|
||||
xlog.Error("Error handling image", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle songs from generate_song action (local file paths)
|
||||
if songPaths, exists := ccm.Metadata[actions.MetadataSongs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
|
||||
xlog.Debug("Sending song from new conversation", "path", path)
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
|
||||
xlog.Error("Error sending song", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle PDFs from generate_pdf action (local file paths)
|
||||
if pdfPaths, exists := ccm.Metadata[actions.MetadataPDFs]; exists {
|
||||
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading PDF file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "document.pdf"
|
||||
}
|
||||
|
||||
xlog.Debug("Sending PDF document from new conversation", "filename", filename, "size", len(data))
|
||||
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
|
||||
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
|
||||
ChatID: chatID,
|
||||
Document: &models.InputFileUpload{
|
||||
Filename: filename,
|
||||
Data: bytes.NewReader(data),
|
||||
},
|
||||
Caption: "Generated PDF",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending PDF", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then send the text message
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: t.channelID,
|
||||
Text: ccm.Content,
|
||||
Text: ccm.Message.Content,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending message", "error", err)
|
||||
@@ -698,7 +990,7 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
t.agent.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("telegram:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Content,
|
||||
Content: ccm.Message.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/connectors/twitter"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/filters"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
func Filters(a *state.AgentConfig) types.JobFilters {
|
||||
|
||||
+3
-3
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/LocalAGI/services/prompts"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
@@ -122,7 +122,7 @@ func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) fu
|
||||
|
||||
dynamicPromptsFound := dynamicPrompts(customDirectory, existingDynamicPromptsConfigs)
|
||||
|
||||
memoryFilePath := memoryPath(a.Name, dynamicConfig)
|
||||
memoryIdxPath := memoryIndexPath(a.Name, dynamicConfig)
|
||||
promptblocks := []agent.DynamicPrompt{}
|
||||
|
||||
for _, c := range a.DynamicPrompts {
|
||||
@@ -137,7 +137,7 @@ func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) fu
|
||||
}
|
||||
promptblocks = append(promptblocks, prompt)
|
||||
case DynamicPromptMemory:
|
||||
_, memory, _ := actions.NewMemoryActions(memoryFilePath, dynamicConfig)
|
||||
_, memory, _, _ := actions.NewMemoryActions(memoryIdxPath, dynamicConfig)
|
||||
|
||||
promptblocks = append(promptblocks,
|
||||
prompts.NewMemoryPrompt(config, memory),
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
)
|
||||
|
||||
+12
-11
@@ -8,15 +8,15 @@ import (
|
||||
|
||||
localagi "github.com/mudler/LocalAGI/pkg/client"
|
||||
"github.com/mudler/LocalAGI/pkg/utils/ptr"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Agent test", func() {
|
||||
Context("Creates an agent and it answers", func() {
|
||||
var _ = Describe("E2E test", func() {
|
||||
Context("Creates an agent and it answers", Label("E2E"), func() {
|
||||
BeforeEach(func() {
|
||||
Eventually(func() error {
|
||||
// test apiURL is working and available
|
||||
@@ -24,7 +24,7 @@ var _ = Describe("Agent test", func() {
|
||||
return err
|
||||
}, "10m", "10s").ShouldNot(HaveOccurred())
|
||||
|
||||
client := localagi.NewClient(localagiURL, "", time.Minute)
|
||||
client := localagi.NewClient(localagiURL, "", 5*time.Minute)
|
||||
err := client.DeleteAgent("testagent1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
@@ -44,7 +44,7 @@ var _ = Describe("Agent test", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Context("Can do user defined tool calls", Ordered, func() {
|
||||
Context("Can do user defined tool calls", Ordered, Label("E2E"), func() {
|
||||
BeforeAll(func() {
|
||||
Eventually(func() error {
|
||||
// test apiURL is working and available
|
||||
@@ -56,13 +56,14 @@ var _ = Describe("Agent test", func() {
|
||||
err := client.DeleteAgent("testagent2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
err = client.CreateAgent(&localagi.AgentConfig{
|
||||
Name: "testagent2",
|
||||
Name: "testagent2",
|
||||
EnableReasoning: true,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("can create a task", func() {
|
||||
client := localagi.NewClient(localagiURL, "", 5*time.Minute)
|
||||
client := localagi.NewClient(localagiURL, "", 10*time.Minute)
|
||||
|
||||
req := localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
@@ -170,7 +171,7 @@ var _ = Describe("Agent test", func() {
|
||||
msg, err := out.ToMessage()
|
||||
if err == nil && msg.Role == "assistant" {
|
||||
xlog.Info("Agent returned message", "message", msg)
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 1))
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 1), fmt.Sprintf("%+v", result.Output))
|
||||
continue
|
||||
}
|
||||
fnc, err := out.ToFunctionToolCall()
|
||||
@@ -204,7 +205,7 @@ var _ = Describe("Agent test", func() {
|
||||
localagi.InputMessage{
|
||||
Type: "message",
|
||||
Role: "user",
|
||||
Content: "Was the appointment created?",
|
||||
Content: "Was the appointment created? Reply using the ChooseAnswer tool.",
|
||||
},
|
||||
},
|
||||
Tools: []localagi.Tool{
|
||||
@@ -235,12 +236,12 @@ var _ = Describe("Agent test", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(result.Output)).To(BeNumerically(">", 0))
|
||||
fnc, err := result.Output[len(result.Output)-1].ToFunctionToolCall()
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(err).ToNot(HaveOccurred(), fmt.Sprintf("%+v", result))
|
||||
Expect(fnc.Arguments).To(ContainSubstring("true"))
|
||||
})
|
||||
|
||||
It("can tool call; web search", func() {
|
||||
client := localagi.NewClient(localagiURL, "", 5*time.Minute)
|
||||
client := localagi.NewClient(localagiURL, "", 10*time.Minute)
|
||||
|
||||
req := localagi.RequestBody{
|
||||
Model: "testagent2",
|
||||
|
||||
+2
-9
@@ -15,9 +15,9 @@ import (
|
||||
coreTypes "github.com/mudler/LocalAGI/core/types"
|
||||
internalTypes "github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/webui/types"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
@@ -25,14 +25,11 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
|
||||
"github.com/donseba/go-htmx"
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/template/html/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
App struct {
|
||||
htmx *htmx.HTMX
|
||||
config *Config
|
||||
*fiber.App
|
||||
sharedState *internalTypes.AgentSharedState
|
||||
@@ -41,16 +38,12 @@ type (
|
||||
|
||||
func NewApp(opts ...Option) *App {
|
||||
config := NewConfig(opts...)
|
||||
engine := html.NewFileSystem(http.FS(viewsfs), ".html")
|
||||
|
||||
// Initialize a new Fiber app
|
||||
// Pass the engine to the Views
|
||||
webapp := fiber.New(fiber.Config{
|
||||
Views: engine,
|
||||
})
|
||||
webapp := fiber.New(fiber.Config{})
|
||||
|
||||
a := &App{
|
||||
htmx: htmx.New(),
|
||||
config: config,
|
||||
App: webapp,
|
||||
sharedState: internalTypes.NewAgentSharedState(5 * time.Minute),
|
||||
|
||||
@@ -1,714 +0,0 @@
|
||||
:root {
|
||||
--primary: #00ff95;
|
||||
--secondary: #ff00b1;
|
||||
--tertiary: #5e00ff;
|
||||
--dark-bg: #111111;
|
||||
--darker-bg: #0a0a0a;
|
||||
--medium-bg: #222222;
|
||||
--light-bg: #333333;
|
||||
--neon-glow: 0 0 8px rgba(0, 255, 149, 0.7);
|
||||
--pink-glow: 0 0 8px rgba(255, 0, 177, 0.7);
|
||||
--purple-glow: 0 0 8px rgba(94, 0, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Glitch effect animation */
|
||||
@keyframes glitch {
|
||||
0% { transform: translate(0); }
|
||||
20% { transform: translate(-2px, 2px); }
|
||||
40% { transform: translate(-2px, -2px); }
|
||||
60% { transform: translate(2px, 2px); }
|
||||
80% { transform: translate(2px, -2px); }
|
||||
100% { transform: translate(0); }
|
||||
}
|
||||
|
||||
/* Neon pulse animation */
|
||||
@keyframes neonPulse {
|
||||
0% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); }
|
||||
50% { text-shadow: 0 0 15px var(--primary), 0 0 25px var(--primary); }
|
||||
100% { text-shadow: 0 0 7px var(--primary), 0 0 10px var(--primary); }
|
||||
}
|
||||
|
||||
/* Scanning line effect */
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background-color: var(--dark-bg);
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, rgba(0, 255, 149, 0.05) 0%, transparent 20%),
|
||||
radial-gradient(circle at 90% 80%, rgba(255, 0, 177, 0.05) 0%, transparent 20%),
|
||||
radial-gradient(circle at 50% 50%, rgba(94, 0, 255, 0.05) 0%, transparent 30%),
|
||||
linear-gradient(180deg, var(--darker-bg) 0%, var(--dark-bg) 100%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: repeating-linear-gradient(
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0, 0, 0, 0.1) 2px,
|
||||
rgba(0, 0, 0, 0.1) 4px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
||||
opacity: 0.7;
|
||||
z-index: 1001;
|
||||
animation: scanline 6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Permanent Marker', cursive;
|
||||
color: var(--primary);
|
||||
text-shadow: var(--neon-glow);
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
animation: neonPulse 2s infinite;
|
||||
}
|
||||
|
||||
h1:hover {
|
||||
animation: glitch 0.3s infinite;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--secondary);
|
||||
text-shadow: var(--pink-glow);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
background-color: rgba(17, 17, 17, 0.85);
|
||||
border: 1px solid var(--primary);
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4), 0 0 0 1px var(--primary), inset 0 0 20px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-box::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary), var(--primary));
|
||||
background-size: 200% 100%;
|
||||
animation: gradientMove 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientMove {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
input, button, textarea, select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--medium-bg);
|
||||
background-color: var(--light-bg);
|
||||
color: white;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="file"], textarea {
|
||||
background-color: var(--light-bg);
|
||||
border-left: 3px solid var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, var(--tertiary), var(--secondary));
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 7px 14px rgba(0, 0, 0, 0.3), 0 0 10px rgba(94, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
select {
|
||||
appearance: none;
|
||||
background-color: var(--light-bg);
|
||||
border-left: 3px solid var(--tertiary);
|
||||
color: white;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background-image: url('data:image/svg+xml;utf8,<svg fill="%23ffffff" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path d="M7 10l5 5 5-5z"/></svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:hover {
|
||||
border-color: var(--secondary);
|
||||
box-shadow: 0 0 0 1px var(--secondary);
|
||||
}
|
||||
|
||||
select:focus {
|
||||
border-color: var(--tertiary);
|
||||
box-shadow: var(--purple-glow);
|
||||
}
|
||||
|
||||
select {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--medium-bg);
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--medium-bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(var(--primary), var(--secondary));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.checkbox-custom {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 5px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.checkbox-custom input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
background-color: var(--light-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--medium-bg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.checkbox-custom:hover .checkmark {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--neon-glow);
|
||||
}
|
||||
|
||||
.checkbox-custom input:checked ~ .checkmark {
|
||||
background: linear-gradient(135deg, var(--primary), var(--tertiary));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.checkbox-custom input:checked ~ .checkmark:after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.checkbox-custom .checkmark:after {
|
||||
left: 8px;
|
||||
top: 4px;
|
||||
width: 6px;
|
||||
height: 12px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Card styling */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(145deg, rgba(34, 34, 34, 0.9), rgba(17, 17, 17, 0.9));
|
||||
border: 1px solid rgba(94, 0, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin: 25px auto;
|
||||
text-align: left;
|
||||
width: 90%;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--primary), var(--secondary), var(--tertiary));
|
||||
transform: scaleX(0);
|
||||
transform-origin: left;
|
||||
transition: transform 0.4s ease-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4), 0 0 15px rgba(94, 0, 255, 0.3);
|
||||
}
|
||||
|
||||
.card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
margin-bottom: 0.8em;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--secondary);
|
||||
transition: color 0.3s;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.card a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--primary);
|
||||
transform: scaleX(0);
|
||||
transform-origin: right;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card a:hover::after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #cccccc;
|
||||
font-size: 1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Button container */
|
||||
.button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Alert and Toast styling */
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
display: none;
|
||||
position: relative;
|
||||
border-left: 4px solid;
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: rgba(0, 255, 149, 0.1);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: rgba(255, 0, 177, 0.1);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
max-width: 350px;
|
||||
padding: 15px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.5);
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast::before {
|
||||
content: "";
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 15px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: linear-gradient(135deg, rgba(0, 255, 149, 0.9), rgba(0, 255, 149, 0.7));
|
||||
color: #111111;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.toast-success::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23111111'%3E%3Cpath d='M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: linear-gradient(135deg, rgba(255, 0, 177, 0.9), rgba(255, 0, 177, 0.7));
|
||||
color: #ffffff;
|
||||
border-left: 4px solid var(--secondary);
|
||||
}
|
||||
|
||||
.toast-error::before {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.action-btn {
|
||||
background: var(--medium-bg);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: linear-gradient(135deg, var(--primary), rgba(0, 255, 149, 0.7));
|
||||
color: #111111;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
box-shadow: 0 0 15px rgba(0, 255, 149, 0.5);
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.pause-btn {
|
||||
background: linear-gradient(135deg, var(--tertiary), rgba(94, 0, 255, 0.7));
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.pause-btn:hover {
|
||||
box-shadow: 0 0 15px rgba(94, 0, 255, 0.5);
|
||||
background: var(--tertiary);
|
||||
}
|
||||
|
||||
/* Badge styling */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary);
|
||||
color: #111111;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-tertiary {
|
||||
background-color: var(--tertiary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Data display tables */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 20px 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.data-table th, .data-table td {
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--medium-bg);
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: rgba(94, 0, 255, 0.2);
|
||||
color: var(--tertiary);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.data-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(odd) td {
|
||||
background-color: rgba(17, 17, 17, 0.6);
|
||||
}
|
||||
|
||||
.data-table tr:nth-child(even) td {
|
||||
background-color: rgba(34, 34, 34, 0.6);
|
||||
}
|
||||
|
||||
.data-table tr:hover td {
|
||||
background-color: rgba(94, 0, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Terminal-style code display */
|
||||
.code-terminal {
|
||||
background-color: #0a0a0a;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff95;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-terminal::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
background: #222;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.code-terminal::after {
|
||||
content: "• • •";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
height: 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.code-terminal pre {
|
||||
margin-top: 25px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-terminal .prompt {
|
||||
color: var(--secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* User info badge */
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: linear-gradient(135deg, rgba(17, 17, 17, 0.8), rgba(34, 34, 34, 0.8));
|
||||
border: 1px solid var(--tertiary);
|
||||
border-radius: 30px;
|
||||
padding: 6px 15px;
|
||||
margin: 10px 0;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: var(--purple-glow);
|
||||
}
|
||||
|
||||
.user-info::before {
|
||||
content: "";
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0.7); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(0, 255, 149, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(0, 255, 149, 0); }
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Responsive design adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/* Agent Form Wizard Styles */
|
||||
.agent-form-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Wizard Sidebar */
|
||||
.wizard-sidebar {
|
||||
width: 250px;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wizard-nav-item {
|
||||
padding: 12px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-left: 4px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-nav-item i {
|
||||
margin-right: 10px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wizard-nav-item:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.wizard-nav-item.active {
|
||||
background: rgba(var(--primary-rgb), 0.15);
|
||||
border-left-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Form Content Area */
|
||||
.form-content-area {
|
||||
flex: 1;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(var(--border-rgb), 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-section.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Improved input styles */
|
||||
.mb-4 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-section input[type="text"],
|
||||
.form-section input[type="number"],
|
||||
.form-section textarea,
|
||||
.form-section select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(var(--border-rgb), 0.8);
|
||||
background-color: var(--input-bg);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-section textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-section input[type="text"]:focus,
|
||||
.form-section input[type="number"]:focus,
|
||||
.form-section textarea:focus,
|
||||
.form-section select:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.button-container {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
/* Navigation controls - improved layout */
|
||||
.wizard-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(var(--border-rgb), 0.5);
|
||||
}
|
||||
|
||||
.wizard-controls-left {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wizard-controls-center {
|
||||
flex: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wizard-controls-right {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(var(--border-rgb), 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.nav-btn:last-child i {
|
||||
margin-right: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.progress-indicator {
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(var(--border-rgb), 0.4);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-dot.active {
|
||||
background-color: var(--primary);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.wizard-controls {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.wizard-controls-left,
|
||||
.wizard-controls-center,
|
||||
.wizard-controls-right {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
// Common utility functions for agent forms
|
||||
const AgentFormUtils = {
|
||||
// Add dynamic component based on template
|
||||
addDynamicComponent: function(sectionId, templateFunction, dataItems) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const newIndex = section.getElementsByClassName(dataItems.className).length;
|
||||
|
||||
// Generate HTML from template function
|
||||
const newHtml = templateFunction(newIndex, dataItems);
|
||||
|
||||
// Add to DOM
|
||||
section.insertAdjacentHTML('beforeend', newHtml);
|
||||
},
|
||||
|
||||
// Process form data into JSON structure
|
||||
processFormData: function(formData) {
|
||||
const jsonData = {};
|
||||
|
||||
// Process basic form fields
|
||||
for (const [key, value] of formData.entries()) {
|
||||
// Skip the array fields as they'll be processed separately
|
||||
if (!key.includes('[') && !key.includes('].')) {
|
||||
// Handle checkboxes
|
||||
if (value === 'on') {
|
||||
jsonData[key] = true;
|
||||
}
|
||||
// Handle numeric fields - specifically kb_results
|
||||
else if (key === 'kb_results') {
|
||||
// Convert to integer or default to 3 if empty
|
||||
jsonData[key] = value ? parseInt(value, 10) : 3;
|
||||
|
||||
// Check if the parse was successful
|
||||
if (isNaN(jsonData[key])) {
|
||||
showToast('Knowledge Base Results must be a number', 'error');
|
||||
return null; // Indicate validation error
|
||||
}
|
||||
}
|
||||
// Handle other numeric fields if needed
|
||||
else if (key === 'periodic_runs' && value) {
|
||||
// Try to parse as number if it looks like one
|
||||
const numValue = parseInt(value, 10);
|
||||
if (!isNaN(numValue) && String(numValue) === value) {
|
||||
jsonData[key] = numValue;
|
||||
} else {
|
||||
jsonData[key] = value;
|
||||
}
|
||||
}
|
||||
else {
|
||||
jsonData[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jsonData;
|
||||
},
|
||||
|
||||
// Process connectors from form
|
||||
processConnectors: function(button) {
|
||||
const connectors = [];
|
||||
const connectorElements = document.querySelectorAll('.connector');
|
||||
|
||||
for (let i = 0; i < connectorElements.length; i++) {
|
||||
const typeSelect = document.getElementById(`connectorType${i}`);
|
||||
if (!typeSelect) {
|
||||
showToast(`Error: Could not find connector type select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const type = typeSelect.value;
|
||||
if (!type) {
|
||||
showToast(`Please select a connector type for connector ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
// Get all config fields for this connector
|
||||
const connector = {
|
||||
type: type,
|
||||
config: {}
|
||||
};
|
||||
|
||||
// Find all config inputs for this connector
|
||||
const configInputs = document.querySelectorAll(`[name^="connectors[${i}][config]"]`);
|
||||
|
||||
// Check if we have a JSON textarea (fallback template)
|
||||
const jsonTextarea = document.getElementById(`connectorConfig${i}`);
|
||||
if (jsonTextarea && jsonTextarea.value) {
|
||||
try {
|
||||
// If it's a JSON textarea, parse it and use the result
|
||||
const jsonConfig = JSON.parse(jsonTextarea.value);
|
||||
// Convert the parsed JSON back to a string for the backend
|
||||
connector.config = JSON.stringify(jsonConfig);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use it as is
|
||||
connector.config = jsonTextarea.value;
|
||||
}
|
||||
} else {
|
||||
// Process individual form fields
|
||||
configInputs.forEach(input => {
|
||||
// Extract the key from the name attribute
|
||||
// Format: connectors[0][config][key]
|
||||
const keyMatch = input.name.match(/\[config\]\[([^\]]+)\]/);
|
||||
if (keyMatch && keyMatch[1]) {
|
||||
const key = keyMatch[1];
|
||||
// For checkboxes, set true/false based on checked state
|
||||
if (input.type === 'checkbox') {
|
||||
connector.config[key] = input.checked ? 'true' : 'false';
|
||||
} else {
|
||||
connector.config[key] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the config object to a JSON string for the backend
|
||||
connector.config = JSON.stringify(connector.config);
|
||||
}
|
||||
|
||||
connectors.push(connector);
|
||||
}
|
||||
|
||||
return connectors;
|
||||
},
|
||||
|
||||
// Process MCP servers from form
|
||||
processMCPServers: function() {
|
||||
const mcpServers = [];
|
||||
const mcpElements = document.querySelectorAll('.mcp_server');
|
||||
|
||||
for (let i = 0; i < mcpElements.length; i++) {
|
||||
const urlInput = document.getElementById(`mcpURL${i}`);
|
||||
const tokenInput = document.getElementById(`mcpToken${i}`);
|
||||
|
||||
if (urlInput && urlInput.value) {
|
||||
const server = {
|
||||
url: urlInput.value
|
||||
};
|
||||
|
||||
// Add token if present
|
||||
if (tokenInput && tokenInput.value) {
|
||||
server.token = tokenInput.value;
|
||||
}
|
||||
|
||||
mcpServers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
return mcpServers;
|
||||
},
|
||||
|
||||
// Process actions from form
|
||||
processActions: function(button) {
|
||||
const actions = [];
|
||||
const actionElements = document.querySelectorAll('.action');
|
||||
|
||||
for (let i = 0; i < actionElements.length; i++) {
|
||||
const nameSelect = document.getElementById(`actionsName${i}`);
|
||||
const configTextarea = document.getElementById(`actionsConfig${i}`);
|
||||
|
||||
if (!nameSelect) {
|
||||
showToast(`Error: Could not find action name select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const name = nameSelect.value;
|
||||
if (!name) {
|
||||
showToast(`Please select an action type for action ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
let config = {};
|
||||
if (configTextarea && configTextarea.value) {
|
||||
try {
|
||||
config = JSON.parse(configTextarea.value);
|
||||
} catch (e) {
|
||||
showToast(`Invalid JSON in action ${i+1} config: ${e.message}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
}
|
||||
|
||||
actions.push({
|
||||
name: name,
|
||||
config: JSON.stringify(config) // Convert to JSON string for backend
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
// Process prompt blocks from form
|
||||
processPromptBlocks: function(button) {
|
||||
const promptBlocks = [];
|
||||
const promptElements = document.querySelectorAll('.prompt_block');
|
||||
|
||||
for (let i = 0; i < promptElements.length; i++) {
|
||||
const nameSelect = document.getElementById(`promptName${i}`);
|
||||
const configTextarea = document.getElementById(`promptConfig${i}`);
|
||||
|
||||
if (!nameSelect) {
|
||||
showToast(`Error: Could not find prompt block name select for index ${i}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
const name = nameSelect.value;
|
||||
if (!name) {
|
||||
showToast(`Please select a prompt block type for block ${i+1}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
|
||||
let config = {};
|
||||
if (configTextarea && configTextarea.value) {
|
||||
try {
|
||||
config = JSON.parse(configTextarea.value);
|
||||
} catch (e) {
|
||||
showToast(`Invalid JSON in prompt block ${i+1} config: ${e.message}`, 'error');
|
||||
button.innerHTML = button.getAttribute('data-original-text');
|
||||
button.disabled = false;
|
||||
return null; // Validation failed
|
||||
}
|
||||
}
|
||||
|
||||
promptBlocks.push({
|
||||
name: name,
|
||||
config: JSON.stringify(config) // Convert to JSON string for backend
|
||||
});
|
||||
}
|
||||
|
||||
return promptBlocks;
|
||||
},
|
||||
|
||||
// Helper function to format config values (for edit form)
|
||||
formatConfigValue: function(configElement, configValue) {
|
||||
if (!configElement) return;
|
||||
|
||||
// If configValue is an object, stringify it
|
||||
if (typeof configValue === 'object' && configValue !== null) {
|
||||
try {
|
||||
configElement.value = JSON.stringify(configValue, null, 2);
|
||||
} catch (e) {
|
||||
console.error('Error stringifying config value:', e);
|
||||
configElement.value = '{}';
|
||||
}
|
||||
}
|
||||
// If it's a string that looks like JSON, try to parse and pretty print it
|
||||
else if (typeof configValue === 'string' && (configValue.startsWith('{') || configValue.startsWith('['))) {
|
||||
try {
|
||||
const parsed = JSON.parse(configValue);
|
||||
configElement.value = JSON.stringify(parsed, null, 2);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, just use the string as is
|
||||
configElement.value = configValue;
|
||||
}
|
||||
}
|
||||
// Otherwise, just use the value as is
|
||||
else {
|
||||
configElement.value = configValue || '';
|
||||
}
|
||||
},
|
||||
|
||||
// Helper function to set select value (with fallback if option doesn't exist)
|
||||
setSelectValue: function(selectElement, value) {
|
||||
if (!selectElement) return;
|
||||
|
||||
// Check if the option exists
|
||||
let optionExists = false;
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
if (selectElement.options[i].value === value) {
|
||||
optionExists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the value if the option exists
|
||||
if (optionExists) {
|
||||
selectElement.value = value;
|
||||
} else if (selectElement.options.length > 0) {
|
||||
// Otherwise, select the first option
|
||||
selectElement.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
|
||||
// Render connector form based on type
|
||||
renderConnectorForm: function(index, type, config = {}) {
|
||||
const formContainer = document.getElementById(`connectorFormContainer${index}`);
|
||||
if (!formContainer) return;
|
||||
|
||||
// Clear existing form
|
||||
formContainer.innerHTML = '';
|
||||
|
||||
// Debug log to see what's happening
|
||||
console.log(`Rendering connector form for type: ${type}`);
|
||||
console.log(`Config for connector:`, config);
|
||||
console.log(`Available templates:`, ConnectorTemplates ? Object.keys(ConnectorTemplates) : 'None');
|
||||
|
||||
// Ensure config is an object
|
||||
let configObj = config;
|
||||
if (typeof config === 'string') {
|
||||
try {
|
||||
configObj = JSON.parse(config);
|
||||
} catch (e) {
|
||||
console.error('Error parsing connector config string:', e);
|
||||
configObj = {};
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a template for this connector type in the global ConnectorTemplates object
|
||||
if (ConnectorTemplates && type && ConnectorTemplates[type]) {
|
||||
console.log(`Found template for ${type}`);
|
||||
// Get the template result which contains HTML and setValues function
|
||||
const templateResult = ConnectorTemplates[type](configObj, index);
|
||||
|
||||
// Set the HTML content
|
||||
formContainer.innerHTML = templateResult.html;
|
||||
|
||||
// Call the setValues function to set input values safely
|
||||
if (typeof templateResult.setValues === 'function') {
|
||||
setTimeout(templateResult.setValues, 0);
|
||||
}
|
||||
} else {
|
||||
console.log(`No template found for ${type}, using fallback`);
|
||||
// Use the fallback template
|
||||
if (ConnectorTemplates && ConnectorTemplates.fallback) {
|
||||
const fallbackResult = ConnectorTemplates.fallback(configObj, index);
|
||||
formContainer.innerHTML = fallbackResult.html;
|
||||
|
||||
if (typeof fallbackResult.setValues === 'function') {
|
||||
setTimeout(fallbackResult.setValues, 0);
|
||||
}
|
||||
} else {
|
||||
// Fallback to generic JSON textarea if no fallback template
|
||||
formContainer.innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="connectorConfig${index}">Connector Config (JSON)</label>
|
||||
<textarea id="connectorConfig${index}"
|
||||
name="connectors[${index}][config]"
|
||||
class="form-control"
|
||||
placeholder='{"key":"value"}'></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Set the value safely after DOM is created
|
||||
setTimeout(function() {
|
||||
const configTextarea = document.getElementById(`connectorConfig${index}`);
|
||||
if (configTextarea) {
|
||||
if (typeof configObj === 'object' && configObj !== null) {
|
||||
configTextarea.value = JSON.stringify(configObj, null, 2);
|
||||
} else if (typeof config === 'string') {
|
||||
configTextarea.value = config;
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// HTML Templates for dynamic elements
|
||||
const AgentFormTemplates = {
|
||||
// Connector template
|
||||
connectorTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="connector mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Connector ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="connectorType${index}">Connector Type</label>
|
||||
<select name="connectors[${index}][type]"
|
||||
id="connectorType${index}"
|
||||
class="form-control"
|
||||
onchange="AgentFormUtils.renderConnectorForm(${index}, this.value)">
|
||||
<option value="">Select Connector Type</option>
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div id="connectorFormContainer${index}">
|
||||
<!-- Connector form will be dynamically inserted here -->
|
||||
<div class="form-group">
|
||||
<div class="placeholder-text">Select a connector type to configure</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.connector').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Connector
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// MCP Server template
|
||||
mcpServerTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="mcp_server mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>MCP Server ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="mcpURL${index}">Server URL</label>
|
||||
<input type="text" id="mcpURL${index}" name="mcp_servers[${index}][url]" placeholder="https://example.com">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="mcpToken${index}">API Token (Optional)</label>
|
||||
<input type="text" id="mcpToken${index}" name="mcp_servers[${index}][token]" placeholder="API token">
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.mcp_server').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Server
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Action template
|
||||
actionTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="action mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Action ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="actionsName${index}">Action Type</label>
|
||||
<select name="actions[${index}][name]" id="actionsName${index}">
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="actionsConfig${index}">Action Config (JSON)</label>
|
||||
<textarea id="actionsConfig${index}" name="actions[${index}][config]" placeholder='{"key":"value"}'>{}</textarea>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.action').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Action
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
|
||||
// Prompt Block template
|
||||
promptBlockTemplate: function(index, data) {
|
||||
return `
|
||||
<div class="prompt_block mb-4 section-box" style="margin-top: 15px; padding: 15px;">
|
||||
<h2>Prompt Block ${index + 1}</h2>
|
||||
<div class="mb-4">
|
||||
<label for="promptName${index}">Prompt Block Type</label>
|
||||
<select name="promptblocks[${index}][name]" id="promptName${index}">
|
||||
${data.options}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="promptConfig${index}">Prompt Block Config (JSON)</label>
|
||||
<textarea id="promptConfig${index}" name="promptblocks[${index}][config]" placeholder='{"key":"value"}'>{}</textarea>
|
||||
</div>
|
||||
<button type="button" class="remove-btn" onclick="this.closest('.prompt_block').remove()">
|
||||
<i class="fas fa-trash"></i> Remove Prompt Block
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize form event listeners
|
||||
function initAgentFormCommon(options = {}) {
|
||||
// Add connector button
|
||||
const addConnectorButton = document.getElementById('addConnectorButton');
|
||||
if (addConnectorButton) {
|
||||
addConnectorButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.connectors) {
|
||||
optionsHtml = options.connectors;
|
||||
}
|
||||
|
||||
// Add new connector form
|
||||
AgentFormUtils.addDynamicComponent('connectorsSection', AgentFormTemplates.connectorTemplate, {
|
||||
className: 'connector',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add MCP server button
|
||||
const addMCPButton = document.getElementById('addMCPButton');
|
||||
if (addMCPButton) {
|
||||
addMCPButton.addEventListener('click', function() {
|
||||
// Add new MCP server form
|
||||
AgentFormUtils.addDynamicComponent('mcpSection', AgentFormTemplates.mcpServerTemplate, {
|
||||
className: 'mcp_server'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add action button
|
||||
const actionButton = document.getElementById('action_button');
|
||||
if (actionButton) {
|
||||
actionButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.actions) {
|
||||
optionsHtml = options.actions;
|
||||
}
|
||||
|
||||
// Add new action form
|
||||
AgentFormUtils.addDynamicComponent('action_box', AgentFormTemplates.actionTemplate, {
|
||||
className: 'action',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add prompt block button
|
||||
const dynamicButton = document.getElementById('dynamic_button');
|
||||
if (dynamicButton) {
|
||||
dynamicButton.addEventListener('click', function() {
|
||||
// Create options string
|
||||
let optionsHtml = '';
|
||||
if (options.promptBlocks) {
|
||||
optionsHtml = options.promptBlocks;
|
||||
}
|
||||
|
||||
// Add new prompt block form
|
||||
AgentFormUtils.addDynamicComponent('dynamic_box', AgentFormTemplates.promptBlockTemplate, {
|
||||
className: 'prompt_block',
|
||||
options: optionsHtml
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Simple toast notification function
|
||||
function showToast(message, type) {
|
||||
// Check if toast container exists, if not create it
|
||||
let toast = document.getElementById('toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'toast';
|
||||
toast.className = 'toast';
|
||||
|
||||
const toastMessage = document.createElement('div');
|
||||
toastMessage.id = 'toast-message';
|
||||
toast.appendChild(toastMessage);
|
||||
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
const toastMessage = document.getElementById('toast-message');
|
||||
|
||||
// Set message
|
||||
toastMessage.textContent = message;
|
||||
|
||||
// Set type class
|
||||
toast.className = 'toast';
|
||||
toast.classList.add(`toast-${type}`);
|
||||
|
||||
// Show toast
|
||||
toast.classList.add('show');
|
||||
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
}, 3000);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Function to show toast notifications with enhanced animation
|
||||
function showToast(message, type) {
|
||||
const toast = document.getElementById('toast');
|
||||
const toastMessage = document.getElementById('toast-message');
|
||||
|
||||
// Set message
|
||||
toastMessage.textContent = message;
|
||||
|
||||
// Set toast type (success/error)
|
||||
toast.className = 'toast';
|
||||
toast.classList.add(type === 'success' ? 'toast-success' : 'toast-error');
|
||||
|
||||
// Show toast with enhanced animation
|
||||
setTimeout(() => {
|
||||
toast.classList.add('toast-visible');
|
||||
}, 100);
|
||||
|
||||
// Hide toast after 3 seconds with animation
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('toast-visible');
|
||||
|
||||
// Clean up after animation completes
|
||||
setTimeout(() => {
|
||||
toast.className = 'toast';
|
||||
}, 400);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Function to create the glitch effect on headings
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const headings = document.querySelectorAll('h1');
|
||||
|
||||
headings.forEach(heading => {
|
||||
heading.addEventListener('mouseover', function() {
|
||||
this.style.animation = 'glitch 0.3s infinite';
|
||||
});
|
||||
|
||||
heading.addEventListener('mouseout', function() {
|
||||
this.style.animation = 'neonPulse 2s infinite';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
/**
|
||||
* Connector Templates
|
||||
*
|
||||
* This file contains templates for all connector types supported by LocalAGI.
|
||||
* Each template is a function that returns an HTML string for the connector's form.
|
||||
*
|
||||
* Note: We don't need to escape HTML in the value attributes because browsers
|
||||
* handle these values safely when setting them via DOM properties after rendering.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Connector Templates
|
||||
* Each function takes a config object and returns an HTML string
|
||||
*/
|
||||
const ConnectorTemplates = {
|
||||
/**
|
||||
* Telegram Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
telegram: function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="telegramToken${index}">Telegram Bot Token</label>
|
||||
<input type="text"
|
||||
id="telegramToken${index}"
|
||||
name="connectors[${index}][config][token]"
|
||||
class="form-control"
|
||||
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
|
||||
<small class="form-text text-muted">Get this from @BotFather on Telegram</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM to avoid XSS
|
||||
const setValues = function() {
|
||||
const input = document.getElementById(`telegramToken${index}`);
|
||||
if (input) input.value = config.token || '';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* Slack Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
slack: function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="slackAppToken${index}">Slack App Token</label>
|
||||
<input type="text"
|
||||
id="slackAppToken${index}"
|
||||
name="connectors[${index}][config][appToken]"
|
||||
class="form-control"
|
||||
placeholder="xapp-...">
|
||||
<small class="form-text text-muted">App-level token starting with xapp-</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="slackBotToken${index}">Slack Bot Token</label>
|
||||
<input type="text"
|
||||
id="slackBotToken${index}"
|
||||
name="connectors[${index}][config][botToken]"
|
||||
class="form-control"
|
||||
placeholder="xoxb-...">
|
||||
<small class="form-text text-muted">Bot token starting with xoxb-</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="slackChannelID${index}">Slack Channel ID</label>
|
||||
<input type="text"
|
||||
id="slackChannelID${index}"
|
||||
name="connectors[${index}][config][channelID]"
|
||||
class="form-control"
|
||||
placeholder="C012345678">
|
||||
<small class="form-text text-muted">Channel ID where the bot will operate</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="slackAlwaysReply${index}"
|
||||
name="connectors[${index}][config][alwaysReply]">
|
||||
<label class="form-check-label" for="slackAlwaysReply${index}">Always Reply</label>
|
||||
<small class="form-text text-muted">If checked, the bot will reply to all messages in the channel</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM to avoid XSS
|
||||
const setValues = function() {
|
||||
const appTokenInput = document.getElementById(`slackAppToken${index}`);
|
||||
const botTokenInput = document.getElementById(`slackBotToken${index}`);
|
||||
const channelIDInput = document.getElementById(`slackChannelID${index}`);
|
||||
const alwaysReplyInput = document.getElementById(`slackAlwaysReply${index}`);
|
||||
|
||||
if (appTokenInput) appTokenInput.value = config.appToken || '';
|
||||
if (botTokenInput) botTokenInput.value = config.botToken || '';
|
||||
if (channelIDInput) channelIDInput.value = config.channelID || '';
|
||||
if (alwaysReplyInput) alwaysReplyInput.checked = config.alwaysReply === 'true';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* Discord Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
discord: function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="discordToken${index}">Discord Bot Token</label>
|
||||
<input type="text"
|
||||
id="discordToken${index}"
|
||||
name="connectors[${index}][config][token]"
|
||||
class="form-control"
|
||||
placeholder="Bot token from Discord Developer Portal">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="discordChannelID${index}">Default Channel ID</label>
|
||||
<input type="text"
|
||||
id="discordChannelID${index}"
|
||||
name="connectors[${index}][config][defaultChannel]"
|
||||
class="form-control"
|
||||
placeholder="Channel ID where the bot will operate">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM
|
||||
const setValues = function() {
|
||||
const tokenInput = document.getElementById(`discordToken${index}`);
|
||||
const channelIDInput = document.getElementById(`discordChannelID${index}`);
|
||||
|
||||
if (tokenInput) tokenInput.value = config.token || '';
|
||||
if (channelIDInput) channelIDInput.value = config.defaultChannel || '';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* GitHub Issues Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
'github-issues': function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="githubIssuesToken${index}">GitHub Personal Access Token</label>
|
||||
<input type="text"
|
||||
id="githubIssuesToken${index}"
|
||||
name="connectors[${index}][config][token]"
|
||||
class="form-control"
|
||||
placeholder="ghp_...">
|
||||
<small class="form-text text-muted">Needs repo and read:org permissions</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubIssuesOwner${index}">Repository Owner</label>
|
||||
<input type="text"
|
||||
id="githubIssuesOwner${index}"
|
||||
name="connectors[${index}][config][owner]"
|
||||
class="form-control"
|
||||
placeholder="username or organization">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubIssuesRepo${index}">Repository Name</label>
|
||||
<input type="text"
|
||||
id="githubIssuesRepo${index}"
|
||||
name="connectors[${index}][config][repository]"
|
||||
class="form-control"
|
||||
placeholder="repository-name">
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="githubIssuesReplyIfNoReplies${index}"
|
||||
name="connectors[${index}][config][replyIfNoReplies]"
|
||||
value="true">
|
||||
<label class="form-check-label" for="githubIssuesReplyIfNoReplies${index}">Reply to issues with no replies</label>
|
||||
<small class="form-text text-muted">If checked, the bot will reply to issues that have no replies yet</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubIssuesPollInterval${index}">Poll Interval (seconds)</label>
|
||||
<input type="number"
|
||||
id="githubIssuesPollInterval${index}"
|
||||
name="connectors[${index}][config][pollInterval]"
|
||||
class="form-control"
|
||||
placeholder="60">
|
||||
<small class="form-text text-muted">How often to check for new issues (in seconds)</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM to avoid XSS
|
||||
const setValues = function() {
|
||||
const tokenInput = document.getElementById(`githubIssuesToken${index}`);
|
||||
const ownerInput = document.getElementById(`githubIssuesOwner${index}`);
|
||||
const repoInput = document.getElementById(`githubIssuesRepo${index}`);
|
||||
const replyIfNoRepliesInput = document.getElementById(`githubIssuesReplyIfNoReplies${index}`);
|
||||
const pollIntervalInput = document.getElementById(`githubIssuesPollInterval${index}`);
|
||||
|
||||
if (tokenInput) tokenInput.value = config.token || '';
|
||||
if (ownerInput) ownerInput.value = config.owner || '';
|
||||
if (repoInput) repoInput.value = config.repository || '';
|
||||
if (replyIfNoRepliesInput) replyIfNoRepliesInput.checked = config.replyIfNoReplies === 'true';
|
||||
if (pollIntervalInput) pollIntervalInput.value = config.pollInterval || '60';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* GitHub PRs Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
'github-prs': function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="githubPRsToken${index}">GitHub Token</label>
|
||||
<input type="text"
|
||||
id="githubPRsToken${index}"
|
||||
name="connectors[${index}][config][token]"
|
||||
class="form-control"
|
||||
placeholder="ghp_...">
|
||||
<small class="form-text text-muted">Personal Access Token with repo permissions</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubPRsOwner${index}">Repository Owner</label>
|
||||
<input type="text"
|
||||
id="githubPRsOwner${index}"
|
||||
name="connectors[${index}][config][owner]"
|
||||
class="form-control"
|
||||
placeholder="username or organization">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubPRsRepo${index}">Repository Name</label>
|
||||
<input type="text"
|
||||
id="githubPRsRepo${index}"
|
||||
name="connectors[${index}][config][repository]"
|
||||
class="form-control"
|
||||
placeholder="repository-name">
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="githubPRsReplyIfNoReplies${index}"
|
||||
name="connectors[${index}][config][replyIfNoReplies]"
|
||||
value="true">
|
||||
<label class="form-check-label" for="githubPRsReplyIfNoReplies${index}">Reply to PRs with no replies</label>
|
||||
<small class="form-text text-muted">If checked, the bot will reply to pull requests that have no replies yet</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="githubPRsPollInterval${index}">Poll Interval (seconds)</label>
|
||||
<input type="number"
|
||||
id="githubPRsPollInterval${index}"
|
||||
name="connectors[${index}][config][pollInterval]"
|
||||
class="form-control"
|
||||
placeholder="60">
|
||||
<small class="form-text text-muted">How often to check for new pull requests (in seconds)</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM to avoid XSS
|
||||
const setValues = function() {
|
||||
const tokenInput = document.getElementById(`githubPRsToken${index}`);
|
||||
const ownerInput = document.getElementById(`githubPRsOwner${index}`);
|
||||
const repoInput = document.getElementById(`githubPRsRepo${index}`);
|
||||
const replyIfNoRepliesInput = document.getElementById(`githubPRsReplyIfNoReplies${index}`);
|
||||
const pollIntervalInput = document.getElementById(`githubPRsPollInterval${index}`);
|
||||
|
||||
if (tokenInput) tokenInput.value = config.token || '';
|
||||
if (ownerInput) ownerInput.value = config.owner || '';
|
||||
if (repoInput) repoInput.value = config.repository || '';
|
||||
if (replyIfNoRepliesInput) replyIfNoRepliesInput.checked = config.replyIfNoReplies === 'true';
|
||||
if (pollIntervalInput) pollIntervalInput.value = config.pollInterval || '60';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* IRC Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
irc: function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="ircServer${index}">IRC Server</label>
|
||||
<input type="text"
|
||||
id="ircServer${index}"
|
||||
name="connectors[${index}][config][server]"
|
||||
class="form-control"
|
||||
placeholder="irc.libera.chat">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ircPort${index}">Port</label>
|
||||
<input type="text"
|
||||
id="ircPort${index}"
|
||||
name="connectors[${index}][config][port]"
|
||||
class="form-control"
|
||||
placeholder="6667">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ircChannel${index}">Channel</label>
|
||||
<input type="text"
|
||||
id="ircChannel${index}"
|
||||
name="connectors[${index}][config][channel]"
|
||||
class="form-control"
|
||||
placeholder="#channel">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ircNick${index}">Nickname</label>
|
||||
<input type="text"
|
||||
id="ircNick${index}"
|
||||
name="connectors[${index}][config][nickname]"
|
||||
class="form-control"
|
||||
placeholder="MyBot">
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="ircAlwaysReply${index}"
|
||||
name="connectors[${index}][config][alwaysReply]"
|
||||
value="true">
|
||||
<label class="form-check-label" for="ircAlwaysReply${index}">Always reply to messages</label>
|
||||
<small class="form-text text-muted">If checked, the bot will always reply to messages, even if they are not directed at it</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM
|
||||
const setValues = function() {
|
||||
const serverInput = document.getElementById(`ircServer${index}`);
|
||||
const portInput = document.getElementById(`ircPort${index}`);
|
||||
const channelInput = document.getElementById(`ircChannel${index}`);
|
||||
const nickInput = document.getElementById(`ircNick${index}`);
|
||||
const alwaysReplyInput = document.getElementById(`ircAlwaysReply${index}`);
|
||||
|
||||
if (serverInput) serverInput.value = config.server || '';
|
||||
if (portInput) portInput.value = config.port || '6667';
|
||||
if (channelInput) channelInput.value = config.channel || '';
|
||||
if (nickInput) nickInput.value = config.nickname || '';
|
||||
if (alwaysReplyInput) alwaysReplyInput.checked = config.alwaysReply === 'true';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* Twitter Connector Template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
twitter: function(config = {}, index) {
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="twitterToken${index}">Twitter API Token</label>
|
||||
<input type="text"
|
||||
id="twitterToken${index}"
|
||||
name="connectors[${index}][config][token]"
|
||||
class="form-control"
|
||||
placeholder="Your Twitter API token">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="twitterBotUsername${index}">Bot Username</label>
|
||||
<input type="text"
|
||||
id="twitterBotUsername${index}"
|
||||
name="connectors[${index}][config][botUsername]"
|
||||
class="form-control"
|
||||
placeholder="@YourBotUsername">
|
||||
<small class="form-text text-muted">Username of your Twitter bot (with or without @)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-check">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
id="twitterNoCharLimit${index}"
|
||||
name="connectors[${index}][config][noCharacterLimit]"
|
||||
value="true">
|
||||
<label class="form-check-label" for="twitterNoCharLimit${index}">Disable character limit</label>
|
||||
<small class="form-text text-muted">If checked, the bot will not enforce Twitter's character limit</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM
|
||||
const setValues = function() {
|
||||
const tokenInput = document.getElementById(`twitterToken${index}`);
|
||||
const botUsernameInput = document.getElementById(`twitterBotUsername${index}`);
|
||||
const noCharLimitInput = document.getElementById(`twitterNoCharLimit${index}`);
|
||||
|
||||
if (tokenInput) tokenInput.value = config.token || '';
|
||||
if (botUsernameInput) botUsernameInput.value = config.botUsername || '';
|
||||
if (noCharLimitInput) noCharLimitInput.checked = config.noCharacterLimit === 'true';
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
},
|
||||
|
||||
/**
|
||||
* Fallback template for any connector without a specific template
|
||||
* @param {Object} config - Existing configuration values
|
||||
* @param {Number} index - Connector index
|
||||
* @returns {Object} HTML template and setValues function
|
||||
*/
|
||||
fallback: function(config = {}, index) {
|
||||
// Convert config to a pretty-printed JSON string
|
||||
let configStr = '{}';
|
||||
try {
|
||||
if (typeof config === 'string') {
|
||||
// If it's already a string, try to parse it first to pretty-print
|
||||
configStr = JSON.stringify(JSON.parse(config), null, 2);
|
||||
} else if (typeof config === 'object' && config !== null) {
|
||||
configStr = JSON.stringify(config, null, 2);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error formatting config:', e);
|
||||
// If it's a string but not valid JSON, just use it as is
|
||||
if (typeof config === 'string') {
|
||||
configStr = config;
|
||||
}
|
||||
}
|
||||
|
||||
// Return HTML without values in the template string
|
||||
const html = `
|
||||
<div class="form-group">
|
||||
<label for="connectorConfig${index}">Connector Configuration (JSON)</label>
|
||||
<textarea id="connectorConfig${index}"
|
||||
name="connectors[${index}][config]"
|
||||
class="form-control"
|
||||
rows="10"
|
||||
placeholder='{"key":"value"}'>${escapeHTML(configStr)}</textarea>
|
||||
<small class="form-text text-muted">Enter the connector configuration as a JSON object</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Function to set values after HTML is added to DOM
|
||||
const setValues = function() {
|
||||
const configInput = document.getElementById(`connectorConfig${index}`);
|
||||
|
||||
if (configInput) configInput.value = configStr;
|
||||
};
|
||||
|
||||
return { html, setValues };
|
||||
}
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Agent Form Wizard - Navigation and UI functionality
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if the wizard exists on the page
|
||||
const wizardSidebar = document.querySelector('.wizard-sidebar');
|
||||
if (!wizardSidebar) return;
|
||||
|
||||
// Get all sections and nav items
|
||||
const navItems = document.querySelectorAll('.wizard-nav-item');
|
||||
const sections = document.querySelectorAll('.form-section');
|
||||
const prevButton = document.getElementById('prevSection');
|
||||
const nextButton = document.getElementById('nextSection');
|
||||
const currentStepLabelEl = document.getElementById('currentStepLabel');
|
||||
const progressDotsContainer = document.getElementById('progressDots');
|
||||
|
||||
// Create progress dots
|
||||
const totalSteps = sections.length;
|
||||
|
||||
// Create dots for each section
|
||||
if (progressDotsContainer) {
|
||||
for (let i = 0; i < totalSteps; i++) {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'progress-dot';
|
||||
dot.setAttribute('data-index', i);
|
||||
dot.addEventListener('click', () => setActiveSection(i));
|
||||
progressDotsContainer.appendChild(dot);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all progress dots
|
||||
const progressDots = document.querySelectorAll('.progress-dot');
|
||||
|
||||
// Track current active section
|
||||
let currentSectionIndex = 0;
|
||||
|
||||
// Initialize
|
||||
updateNavigation();
|
||||
|
||||
// Add click events to nav items
|
||||
navItems.forEach((item, index) => {
|
||||
item.addEventListener('click', () => {
|
||||
setActiveSection(index);
|
||||
});
|
||||
});
|
||||
|
||||
// Add click events to prev/next buttons
|
||||
if (prevButton) {
|
||||
prevButton.addEventListener('click', () => {
|
||||
if (currentSectionIndex > 0) {
|
||||
setActiveSection(currentSectionIndex - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (nextButton) {
|
||||
nextButton.addEventListener('click', () => {
|
||||
if (currentSectionIndex < sections.length - 1) {
|
||||
setActiveSection(currentSectionIndex + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active section and update navigation
|
||||
*/
|
||||
function setActiveSection(index) {
|
||||
// Remove active class from all sections and nav items
|
||||
sections.forEach(section => section.classList.remove('active'));
|
||||
navItems.forEach(item => item.classList.remove('active'));
|
||||
progressDots.forEach(dot => dot.classList.remove('active'));
|
||||
|
||||
// Add active class to current section, nav item, and dot
|
||||
sections[index].classList.add('active');
|
||||
navItems[index].classList.add('active');
|
||||
if (progressDots[index]) {
|
||||
progressDots[index].classList.add('active');
|
||||
}
|
||||
|
||||
// Update current section index
|
||||
currentSectionIndex = index;
|
||||
|
||||
// Update navigation state
|
||||
updateNavigation();
|
||||
|
||||
// Scroll to top of section
|
||||
sections[index].scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update navigation buttons and progress
|
||||
*/
|
||||
function updateNavigation() {
|
||||
// Update section label
|
||||
if (currentStepLabelEl && navItems[currentSectionIndex]) {
|
||||
// Extract text content without the icon
|
||||
const navText = navItems[currentSectionIndex].textContent.trim();
|
||||
currentStepLabelEl.textContent = navText;
|
||||
}
|
||||
|
||||
// Update prev/next buttons
|
||||
if (prevButton) {
|
||||
prevButton.disabled = currentSectionIndex === 0;
|
||||
prevButton.style.opacity = currentSectionIndex === 0 ? 0.5 : 1;
|
||||
}
|
||||
|
||||
if (nextButton) {
|
||||
nextButton.disabled = currentSectionIndex === sections.length - 1;
|
||||
nextButton.style.opacity = currentSectionIndex === sections.length - 1 ? 0.5 : 1;
|
||||
|
||||
// Change text for last step
|
||||
if (currentSectionIndex === sections.length - 2) {
|
||||
nextButton.innerHTML = 'Finish <i class="fas fa-check"></i>';
|
||||
} else {
|
||||
nextButton.innerHTML = 'Next <i class="fas fa-arrow-right"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate current section before proceeding
|
||||
function validateCurrentSection() {
|
||||
// Implement validation logic here based on the current section
|
||||
// Return true if valid, false if not
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add to initAgentFormCommon function if it exists
|
||||
if (typeof window.initAgentFormCommon === 'function') {
|
||||
const originalInit = window.initAgentFormCommon;
|
||||
|
||||
window.initAgentFormCommon = function(options) {
|
||||
// Call the original initialization function
|
||||
originalInit(options);
|
||||
|
||||
// Now initialize the wizard navigation
|
||||
setActiveSection(0);
|
||||
};
|
||||
}
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 933 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -1,291 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Actions Playground</title>
|
||||
{{template "old/views/partials/header"}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "old/views/partials/menu"}}
|
||||
|
||||
<!-- Toast for notifications -->
|
||||
<div id="toast" class="toast">
|
||||
<span id="toast-message"></span>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto">
|
||||
<header class="text-center mb-8">
|
||||
<h1 class="text-4xl md:text-6xl font-bold">Actions Playground</h1>
|
||||
<p class="mt-4 text-gray-400">Test and execute actions directly from the UI</p>
|
||||
</header>
|
||||
|
||||
<section class="section-box mb-8">
|
||||
<h2 class="mb-4">Select an Action</h2>
|
||||
<div class="mb-4">
|
||||
<label for="action-select" class="block mb-2">Available Actions:</label>
|
||||
<select id="action-select" class="w-full">
|
||||
<option value="">-- Select an action --</option>
|
||||
<!-- Actions will be loaded here -->
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="config-section" class="section-box mb-8 hidden">
|
||||
<h2 class="mb-4">Action Configuration</h2>
|
||||
<form id="action-form">
|
||||
<div class="mb-6">
|
||||
<label for="config-json" class="block mb-2">Configuration (JSON):</label>
|
||||
<textarea id="config-json" class="w-full" rows="5" placeholder='{"key": "value"}'>{}</textarea>
|
||||
<p class="text-xs text-gray-400 mt-1">Enter JSON configuration for the action</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label for="params-json" class="block mb-2">Parameters (JSON):</label>
|
||||
<textarea id="params-json" class="w-full" rows="5" placeholder='{"key": "value"}'>{}</textarea>
|
||||
<p class="text-xs text-gray-400 mt-1">Enter JSON parameters for the action</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="action-btn start-btn">
|
||||
<i class="fas fa-play"></i> Execute Action
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="results-section" class="section-box mb-8 hidden">
|
||||
<h2 class="mb-4">Action Results</h2>
|
||||
<div id="action-results">
|
||||
<!-- Results will appear here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="text-center text-gray-500 text-sm mb-8">
|
||||
<p>© 2025 LocalAGI.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load available actions
|
||||
fetchActions();
|
||||
|
||||
// Handle action selection
|
||||
document.getElementById('action-select').addEventListener('change', function() {
|
||||
const actionId = this.value;
|
||||
if (actionId) {
|
||||
document.getElementById('config-section').classList.remove('hidden');
|
||||
} else {
|
||||
document.getElementById('config-section').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Hide results when changing actions
|
||||
document.getElementById('results-section').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('action-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const actionId = document.getElementById('action-select').value;
|
||||
if (actionId) {
|
||||
executeAction(actionId);
|
||||
} else {
|
||||
showToast('Please select an action first', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function fetchActions() {
|
||||
fetch('/api/actions')
|
||||
.then(response => response.json())
|
||||
.then(actions => {
|
||||
const select = document.getElementById('action-select');
|
||||
|
||||
// Clear existing options except the first one
|
||||
while (select.options.length > 1) {
|
||||
select.remove(1);
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.text = 'No actions available';
|
||||
option.disabled = true;
|
||||
select.add(option);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add options for each action
|
||||
actions.forEach(actionId => {
|
||||
const option = document.createElement('option');
|
||||
option.value = actionId;
|
||||
option.text = actionId; // Using actionId as display text
|
||||
select.add(option);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching actions:', error);
|
||||
showToast('Failed to load actions: ' + error.message, 'error');
|
||||
|
||||
const select = document.getElementById('action-select');
|
||||
const option = document.createElement('option');
|
||||
option.text = 'Error loading actions';
|
||||
option.disabled = true;
|
||||
|
||||
// Clear existing options except the first one
|
||||
while (select.options.length > 1) {
|
||||
select.remove(1);
|
||||
}
|
||||
|
||||
select.add(option);
|
||||
});
|
||||
}
|
||||
|
||||
function executeAction(actionId) {
|
||||
// Get the JSON data from textareas
|
||||
let config = {};
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
const configText = document.getElementById('config-json').value.trim();
|
||||
if (configText && configText !== '{}') {
|
||||
config = JSON.parse(configText);
|
||||
}
|
||||
|
||||
const paramsText = document.getElementById('params-json').value.trim();
|
||||
if (paramsText && paramsText !== '{}') {
|
||||
params = JSON.parse(paramsText);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Invalid JSON: ' + error.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the results section with loading indicator
|
||||
const resultsSection = document.getElementById('results-section');
|
||||
resultsSection.classList.remove('hidden');
|
||||
|
||||
const resultDiv = document.getElementById('action-results');
|
||||
resultDiv.innerHTML = `
|
||||
<div class="flex justify-center items-center py-8">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Execute the action
|
||||
fetch(`/api/action/${actionId}/run`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
config: config,
|
||||
params: params
|
||||
})
|
||||
})
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(result => {
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
// Display the results
|
||||
showActionResult(result);
|
||||
showToast('Action executed successfully!', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="alert alert-error" style="display: block;">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i> Error: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
showToast('Error executing action', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showActionResult(result) {
|
||||
const resultDiv = document.getElementById('action-results');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Display result
|
||||
if (result.Result) {
|
||||
html += `
|
||||
<div class="mb-4">
|
||||
<h4 class="text-lg mb-2" style="color: var(--secondary);">Result:</h4>
|
||||
<div class="code-terminal">
|
||||
<pre>${escapeHtml(result.Result)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Display metadata if available
|
||||
if (result.Metadata && Object.keys(result.Metadata).length > 0) {
|
||||
html += `
|
||||
<div class="mb-4">
|
||||
<h4 class="text-lg mb-2" style="color: var(--secondary);">Metadata:</h4>
|
||||
<div class="code-terminal">
|
||||
<pre>${escapeHtml(JSON.stringify(result.Metadata, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
html = '<p class="text-gray-400">No results returned from the action.</p>';
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = html;
|
||||
|
||||
// Scroll to results
|
||||
resultDiv.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const toast = document.getElementById('toast');
|
||||
const toastMessage = document.getElementById('toast-message');
|
||||
|
||||
toastMessage.textContent = message;
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.classList.add('toast-visible');
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('toast-visible');
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid var(--tertiary);
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.code-terminal {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,332 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agent List</title>
|
||||
{{template "old/views/partials/header"}}
|
||||
<style>
|
||||
.avatar-placeholder {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||||
color: var(--primary);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid var(--primary);
|
||||
box-shadow: var(--neon-glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-placeholder::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--primary);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
animation: loading-progress 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes loading-progress {
|
||||
0% { width: 0; }
|
||||
50% { width: 100%; }
|
||||
100% { width: 0; }
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{{template "old/views/partials/menu"}}
|
||||
|
||||
<!-- Toast for notifications -->
|
||||
<div id="toast" class="toast">
|
||||
<span id="toast-message"></span>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto">
|
||||
<header class="text-center mb-8">
|
||||
<h1 class="text-4xl md:text-6xl font-bold">Agent List</h1>
|
||||
<p class="mt-4 text-gray-400">Manage and interact with your AI agents</p>
|
||||
</header>
|
||||
|
||||
<div class="button-container justify-center mb-6">
|
||||
<a href="/old/create" class="action-btn start-btn">
|
||||
<i class="fas fa-plus-circle"></i> Add New Agent
|
||||
</a>
|
||||
<button id="toggle-import" class="action-btn" style="background: linear-gradient(135deg, var(--tertiary), #4a76a8);">
|
||||
<i class="fas fa-file-import"></i> Import Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section id="import-section" class="hidden mb-8">
|
||||
<div class="section-box">
|
||||
<h2>Import Agent</h2>
|
||||
|
||||
<!-- Response Messages Container -->
|
||||
<div id="response-container">
|
||||
<!-- Success Alert -->
|
||||
<div id="success-alert" class="alert alert-success" style="display: none;">
|
||||
Agent imported successfully! The page will refresh in a moment.
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<div id="error-alert" class="alert alert-error" style="display: none;">
|
||||
<span id="error-message">Error importing agent.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id='import-form' hx-encoding='multipart/form-data' hx-post='/settings/import' hx-target="#response-container" hx-swap="none">
|
||||
<div class="mb-4">
|
||||
<label for="file" class="block mb-2">Select Agent File:</label>
|
||||
<input type='file' name='file' id='file'>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<button id="import-button" type="submit" class="action-btn">
|
||||
<i class="fas fa-cloud-upload-alt"></i> Import Agent
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{{ $status := .Status }}
|
||||
{{ range .Agents }}
|
||||
<div hx-ext="sse" data-agent-name="{{.}}" class="card">
|
||||
<div class="flex flex-col items-center text-center p-4">
|
||||
<div class="avatar-container mb-4">
|
||||
<img src="/avatars/{{.}}.png" alt="{{.}}" class="w-24 h-24 rounded-full"
|
||||
style="border: 2px solid var(--primary); box-shadow: var(--neon-glow); display: none;"
|
||||
onload="this.style.display = 'block'; this.nextElementSibling.style.display = 'none';"
|
||||
onerror="this.style.display = 'none'; this.nextElementSibling.style.display = 'flex';">
|
||||
<div class="avatar-placeholder">
|
||||
<span class="placeholder-text"><i class="fas fa-sync fa-spin"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
<h2>{{.}}</h2>
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<span class="badge {{ if eq (index $status .) true }}badge-primary{{ else }}badge-secondary{{ end }} mr-2">
|
||||
{{ if eq (index $status .) true }}Active{{ else }}Inactive{{ end }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 w-full mb-4">
|
||||
<a href="/old/status/{{.}}" class="action-btn flex items-center justify-center"
|
||||
style="background: linear-gradient(135deg, #2a2a2a, #3a3a3a);">
|
||||
<i class="fas fa-info-circle mr-2"></i> Status
|
||||
</a>
|
||||
<a href="/old/talk/{{.}}" class="action-btn flex items-center justify-center"
|
||||
style="background: linear-gradient(135deg, #2a2a2a, #3a3a3a);">
|
||||
<i class="fas fa-comments mr-2"></i> Talk
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 w-full">
|
||||
<button class="action-btn toggle-btn col-span-1"
|
||||
data-agent="{{.}}"
|
||||
data-active="{{ if eq (index $status .) true }}true{{ else }}false{{ end }}">
|
||||
{{ if eq (index $status .) true }}
|
||||
<i class="fas fa-pause"></i> Pause
|
||||
{{ else }}
|
||||
<i class="fas fa-play"></i> Start
|
||||
{{ end }}
|
||||
</button>
|
||||
<a href="/old/settings/{{.}}" class="action-btn col-span-1 flex items-center justify-center"
|
||||
style="background: linear-gradient(135deg, #2a2a2a, #3a3a3a);">
|
||||
<i class="fas fa-cog"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</section>
|
||||
|
||||
<div class="user-info mt-8 mb-4">
|
||||
<span></span>
|
||||
<span class="timestamp"></span>
|
||||
</div>
|
||||
|
||||
<footer class="text-center text-gray-500 text-sm mb-8">
|
||||
<p>© 2025 LocalAGI.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Image loading handler
|
||||
document.querySelectorAll('.avatar-container img').forEach(img => {
|
||||
// Check if image is already cached
|
||||
if (img.complete) {
|
||||
if (img.naturalHeight === 0) {
|
||||
// Image failed to load
|
||||
img.style.display = 'none';
|
||||
img.nextElementSibling.style.display = 'flex';
|
||||
} else {
|
||||
// Image loaded successfully
|
||||
img.style.display = 'block';
|
||||
img.nextElementSibling.style.display = 'none';
|
||||
}
|
||||
}
|
||||
// onload and onerror handlers are already in the HTML
|
||||
});
|
||||
|
||||
const importSection = document.getElementById('import-section');
|
||||
const toggleImport = document.getElementById('toggle-import');
|
||||
|
||||
// Toggle import section visibility
|
||||
toggleImport.addEventListener('click', function() {
|
||||
importSection.classList.toggle('hidden');
|
||||
// Add glitch effect when showing
|
||||
if (!importSection.classList.contains('hidden')) {
|
||||
importSection.style.animation = 'glitch 0.3s';
|
||||
setTimeout(() => {
|
||||
importSection.style.animation = '';
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle import form submission
|
||||
document.getElementById('import-form').addEventListener('htmx:afterRequest', function(event) {
|
||||
const xhr = event.detail.xhr;
|
||||
const successAlert = document.getElementById('success-alert');
|
||||
const errorAlert = document.getElementById('error-alert');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
|
||||
// Hide both alerts initially
|
||||
successAlert.style.display = 'none';
|
||||
errorAlert.style.display = 'none';
|
||||
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (response.status === "ok") {
|
||||
// Show success message
|
||||
successAlert.style.display = 'block';
|
||||
showToast("Agent imported successfully!", "success");
|
||||
|
||||
// Refresh the page after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else if (response.error) {
|
||||
// Show error message
|
||||
errorMessage.textContent = response.error;
|
||||
errorAlert.style.display = 'block';
|
||||
showToast("Import failed: " + response.error, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle parsing error
|
||||
errorMessage.textContent = "Invalid response format";
|
||||
errorAlert.style.display = 'block';
|
||||
showToast("Invalid response format", "error");
|
||||
}
|
||||
} else {
|
||||
// Handle HTTP error
|
||||
errorMessage.textContent = "Server error: " + xhr.status;
|
||||
errorAlert.style.display = 'block';
|
||||
showToast("Server error: " + xhr.status, "error");
|
||||
}
|
||||
});
|
||||
|
||||
// Handle toggle buttons - using pure JavaScript
|
||||
document.querySelectorAll('.toggle-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const agent = this.getAttribute('data-agent');
|
||||
const isActive = this.getAttribute('data-active') === 'true';
|
||||
const endpoint = isActive ? `/api/agent/${agent}/pause` : `/api/agent/${agent}/start`;
|
||||
|
||||
// Add animation
|
||||
this.style.animation = 'pulse 0.5s';
|
||||
|
||||
// Create a new XMLHttpRequest
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', endpoint);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
|
||||
xhr.onload = () => {
|
||||
// Clear animation
|
||||
this.style.animation = '';
|
||||
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
if (response.status === "ok") {
|
||||
// Toggle the button state
|
||||
const newState = !isActive;
|
||||
this.setAttribute('data-active', newState ? 'true' : 'false');
|
||||
|
||||
// Update button text and icon
|
||||
if (newState) {
|
||||
this.innerHTML = '<i class="fas fa-pause"></i> Pause';
|
||||
} else {
|
||||
this.innerHTML = '<i class="fas fa-play"></i> Start';
|
||||
}
|
||||
|
||||
// Show success toast
|
||||
const action = isActive ? 'pause' : 'start';
|
||||
showToast(`Agent "${agent}" ${action}ed successfully`, 'success');
|
||||
|
||||
// Update the status badge
|
||||
updateAgentStatus(agent, newState);
|
||||
} else if (response.error) {
|
||||
// Show error toast
|
||||
showToast(`Error: ${response.error}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle parsing error
|
||||
showToast("Invalid response format", 'error');
|
||||
console.error("Error parsing response:", e);
|
||||
}
|
||||
} else {
|
||||
// Handle HTTP error
|
||||
showToast(`Server error: ${xhr.status}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
// Clear animation
|
||||
this.style.animation = '';
|
||||
showToast("Network error occurred", 'error');
|
||||
console.error("Network error occurred");
|
||||
};
|
||||
|
||||
// Send the request
|
||||
xhr.send(JSON.stringify({}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Function to update agent status in the UI
|
||||
function updateAgentStatus(agentName, isOnline) {
|
||||
// Find the card for this agent
|
||||
const cards = document.querySelectorAll('.card');
|
||||
cards.forEach(card => {
|
||||
const agentTitle = card.querySelector('h2').textContent;
|
||||
if (agentTitle === agentName) {
|
||||
// Update the badge
|
||||
const badge = card.querySelector('.badge');
|
||||
if (isOnline) {
|
||||
badge.className = 'badge badge-primary mr-2';
|
||||
badge.textContent = 'Active';
|
||||
} else {
|
||||
badge.className = 'badge badge-secondary mr-2';
|
||||
badge.textContent = 'Inactive';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Smart Agent Interface</title>
|
||||
{{template "old/views/partials/header"}}
|
||||
<style>
|
||||
body { overflow: hidden; }
|
||||
.chat-container { height: 90vh; display: flex; flex-direction: column; }
|
||||
.chat-messages { overflow-y: auto; flex-grow: 1; }
|
||||
.htmx-indicator{
|
||||
opacity:0;
|
||||
transition: opacity 10ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator{
|
||||
opacity:1
|
||||
}
|
||||
/* Loader (https://cssloaders.github.io/) */
|
||||
.loader {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin:15px auto;
|
||||
position: relative;
|
||||
color: #FFF;
|
||||
box-sizing: border-box;
|
||||
animation: animloader 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes animloader {
|
||||
0% { box-shadow: 14px 0 0 -2px, 38px 0 0 -2px, -14px 0 0 -2px, -38px 0 0 -2px; }
|
||||
25% { box-shadow: 14px 0 0 -2px, 38px 0 0 -2px, -14px 0 0 -2px, -38px 0 0 2px; }
|
||||
50% { box-shadow: 14px 0 0 -2px, 38px 0 0 -2px, -14px 0 0 2px, -38px 0 0 -2px; }
|
||||
75% { box-shadow: 14px 0 0 2px, 38px 0 0 -2px, -14px 0 0 -2px, -38px 0 0 -2px; }
|
||||
100% { box-shadow: 14px 0 0 -2px, 38px 0 0 2px, -14px 0 0 -2px, -38px 0 0 -2px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-900 p-4 text-white font-sans" hx-ext="sse" sse-connect="/sse/{{.Name}}">
|
||||
{{template "old/views/partials/menu"}}
|
||||
<div class="chat-container bg-gray-800 shadow-lg rounded-lg" >
|
||||
|
||||
<!-- Chat Header -->
|
||||
<div class="border-b border-gray-700 p-4">
|
||||
<h1 class="text-lg font-semibold">Talk to '{{.Name}}'</h1>
|
||||
</div>
|
||||
|
||||
<!-- Chat Messages -->
|
||||
<div class="chat-messages p-4">
|
||||
<!-- Client Box -->
|
||||
<div class="bg-gray-700 p-4">
|
||||
<h2 class="text-sm font-semibold">Clients:</h2>
|
||||
<div id="clients" class="text-sm text-gray-300">
|
||||
<!-- Status updates dynamically here -->
|
||||
<div sse-swap="clients"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- HUD Box -->
|
||||
<div class="bg-gray-700 p-4">
|
||||
<h2 class="text-sm font-semibold">Status:</h2>
|
||||
<div id="hud" class="text-sm text-gray-300">
|
||||
<!-- Status updates dynamically here -->
|
||||
<div sse-swap="hud"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div sse-swap="messages" hx-swap="beforeend" id="messages" hx-on:htmx:after-settle="document.getElementById('messages').scrollIntoView(false)"></div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Status Box -->
|
||||
<div class="bg-gray-700 p-4">
|
||||
<h2 class="text-sm font-semibold">Agent:</h2>
|
||||
<div id="agentStatus" class="text-sm text-gray-300">
|
||||
<!-- Status updates dynamically here -->
|
||||
<div sse-swap="status" ></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<div sse-swap="message_status"></div>
|
||||
<input id="inputMessage" name="message" type="text" hx-post="/old/chat/{{.Name}}" hx-target="#results" hx-indicator=".htmx-indicator"
|
||||
class="p-2 border rounded w-full bg-gray-600 text-white placeholder-gray-300" placeholder="Type a message..." _="on htmx:afterRequest set my value to ''">
|
||||
<div class="my-2 htmx-indicator" ></div>
|
||||
<div id="results" class="flex justify-center"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user