mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-07-28 05:01:06 -04:00
Compare commits
1 Commits
main
..
fix/content
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e67105f0c |
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Go
|
||||
|
||||
@@ -11,11 +11,10 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
containerImages:
|
||||
#runs-on: ubuntu-latest
|
||||
runs-on: arc-runner-localagent
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -58,7 +57,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
with:
|
||||
images: quay.io/mudler/localagi
|
||||
tags: |
|
||||
@@ -89,7 +88,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
id: prep
|
||||
@@ -132,7 +131,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804
|
||||
with:
|
||||
images: quay.io/mudler/localagi-sshbox
|
||||
tags: |
|
||||
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.26.0'
|
||||
go-version: '>=1.17.0'
|
||||
- name: Run tests
|
||||
run: |
|
||||
make tests
|
||||
@@ -26,9 +26,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.26.0'
|
||||
go-version: '>=1.17.0'
|
||||
- run: |
|
||||
make tests-e2e
|
||||
@@ -8,5 +8,3 @@ LocalAGI
|
||||
**/.env
|
||||
.vscode
|
||||
volumes/
|
||||
example/scheduler/scheduler
|
||||
example/scheduler/example_tasks.json
|
||||
|
||||
+3
-3
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
|
||||
# Build the React UI
|
||||
RUN bun run build
|
||||
|
||||
# Use a temporary build image based on Golang 1.26-alpine
|
||||
FROM golang:1.26-alpine AS builder
|
||||
# Use a temporary build image based on Golang 1.24-alpine
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Define argument for linker flags
|
||||
ARG LDFLAGS="-s -w"
|
||||
@@ -61,4 +61,4 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY --from=builder /work/localagi /localagi
|
||||
|
||||
# Define the command that will be run when the container is started
|
||||
ENTRYPOINT ["/localagi", "serve"]
|
||||
ENTRYPOINT ["/localagi"]
|
||||
|
||||
@@ -15,7 +15,7 @@ run-nokb:
|
||||
$(MAKE) run KBDISABLEINDEX=true
|
||||
|
||||
webui/react-ui/dist:
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
|
||||
|
||||
.PHONY: build
|
||||
build: webui/react-ui/dist
|
||||
|
||||
@@ -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. Create Agents with a couple of clicks, connect via MCP, and use built-in **Skills** (manage skills in the Web UI and enable them per agent). 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). Skills follow the [skillserver](https://github.com/mudler/skillserver) format and can be created, imported, or synced from git.
|
||||
**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).
|
||||
|
||||
## 🛡️ Take Back Your Privacy
|
||||
|
||||
@@ -33,13 +33,12 @@ LocalAGI ensures your data stays exactly where you want it—on your hardware. N
|
||||
- 🤖 **Advanced Agent Teaming**: Instantly create cooperative agent teams from a single prompt.
|
||||
- 📡 **Connectors**: Built-in integrations with Discord, Slack, Telegram, GitHub Issues, and IRC.
|
||||
- 🛠 **Comprehensive REST API**: Seamless integration into your workflows. Every agent created will support OpenAI Responses API out of the box.
|
||||
- 📚 **Short & Long-Term Memory**: Built-in knowledge base (RAG) for collections, file uploads, and semantic search. Manage collections in the Web UI under **Knowledge base**; agents with "Knowledge base" enabled use it automatically (implementation uses [LocalRecall](https://github.com/mudler/LocalRecall) libraries).
|
||||
- 📚 **Short & Long-Term Memory**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall).
|
||||
- 🧠 **Planning & Reasoning**: Agents intelligently plan, reason, and adapt.
|
||||
- 🔄 **Periodic Tasks**: Schedule tasks with cron-like syntax.
|
||||
- 💾 **Memory Management**: Control memory usage with options for long-term and summary memory.
|
||||
- 🖼 **Multimodal Support**: Ready for vision, text, and more.
|
||||
- 🔧 **Extensible Custom Actions**: Easily script dynamic agent behaviors in Go (interpreted, no compilation!).
|
||||
- 📚 **Built-in Skills**: Manage reusable agent skills in the Web UI (create, edit, import/export, git sync). Enable "Skills" per agent to inject skill tools and the skill list into the agent.
|
||||
- 🛠 **Fully Customizable Models**: Use your own models or integrate seamlessly with [LocalAI](https://github.com/mudler/LocalAI).
|
||||
- 📊 **Observability**: Monitor agent status and view detailed observable updates in real-time.
|
||||
|
||||
@@ -108,7 +107,7 @@ Still having issues? see this Youtube video: https://youtu.be/HtVwIxW3ePg
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3><a href="https://github.com/mudler/LocalRecall">LocalRecall</a></h3>
|
||||
<p>A REST-ful API and knowledge base management system. LocalAGI embeds this functionality: the Web UI includes a <strong>Knowledge base</strong> section and the same collections API, so you no longer need to run LocalRecall separately.</p>
|
||||
<p>A REST-ful API and knowledge base management system that provides persistent memory and storage capabilities for AI agents.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -196,7 +195,7 @@ Good (relatively small) models that have been tested are:
|
||||
- **✓ Flexible Model Integration**: Supports GGUF, GGML, and more thanks to [LocalAI](https://github.com/mudler/LocalAI).
|
||||
- **✓ Developer-Friendly**: Rich APIs and intuitive interfaces.
|
||||
- **✓ Effortless Setup**: Simple Docker compose setups and pre-built binaries.
|
||||
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, built-in Skills, LocalAGI has it all.
|
||||
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, LocalAGI has it all.
|
||||
|
||||
## 🌟 Screenshots
|
||||
|
||||
@@ -225,7 +224,6 @@ Explore detailed documentation including:
|
||||
- [REST API Documentation](#rest-api)
|
||||
- [Connector Configuration](#connectors)
|
||||
- [Agent Configuration](#agent-configuration-reference)
|
||||
- [Skills](#3-skills)
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
@@ -239,16 +237,11 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_LLM_API_KEY` | API authentication |
|
||||
| `LOCALAGI_TIMEOUT` | Request timeout settings |
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | Optional URL when using an external LocalRAG URL; not used for built-in knowledge base |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for the app (defaults to ":3000") |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `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 |
|
||||
|
||||
For the built-in knowledge base, optional env (defaults use `LOCALAGI_STATE_DIR`): `COLLECTION_DB_PATH`, `FILE_ASSETS`, `VECTOR_ENGINE` (e.g. `chromem`, `postgres`), `EMBEDDING_MODEL`, `DATABASE_URL` (when `VECTOR_ENGINE=postgres`).
|
||||
|
||||
Skills are stored in a fixed `skills` subdirectory under `LOCALAGI_STATE_DIR` (e.g. `/pool/skills` in Docker). Git repo config for skills lives in that directory. No extra environment variables are required.
|
||||
|
||||
## Installation Options
|
||||
|
||||
### Pre-Built Binaries
|
||||
@@ -342,16 +335,15 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
// Create a new agent pool (call pool.SetRAGProvider(...) for knowledge base; see main.go)
|
||||
// Create a new agent pool
|
||||
pool, err := state.NewAgentPool(
|
||||
"default-model", // default model name
|
||||
"default-multimodal-model", // default multimodal model
|
||||
"transcription-model", // default transcription model
|
||||
"en", // default transcription language
|
||||
"tts-model", // default TTS model
|
||||
"image-model", // image generation model
|
||||
"http://localhost:8080", // API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"http://localhost:8081", // LocalRAG API URL
|
||||
func(config *AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
// Define available actions for agents
|
||||
return func(ctx context.Context, pool *AgentPool) []types.Action {
|
||||
@@ -378,9 +370,8 @@ pool, err := state.NewAgentPool(
|
||||
// Add your custom filters here
|
||||
}
|
||||
},
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
nil, // skills service (optional)
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
)
|
||||
|
||||
// Create a new agent in the pool
|
||||
@@ -702,16 +693,6 @@ You can create MCP servers in any language that supports the MCP protocol and ad
|
||||
- **Testing**: Test your MCP servers independently before integrating with LocalAGI
|
||||
- **Resource Management**: Ensure your MCP servers properly clean up resources
|
||||
|
||||
### 3. Skills
|
||||
|
||||
LocalAGI includes built-in **Skills** management. Skills are reusable instructions and resources (scripts, references, assets) that agents can use when "Enable Skills" is turned on for that agent.
|
||||
|
||||
- **Skills section (Web UI)**: Open **Skills** in the sidebar. Skills are stored under the state directory (`STATE_DIR/skills`). Create, edit, search, import, and export skills. You can also add git repositories to sync skills from.
|
||||
- **Per-agent**: In agent creation or settings, enable **Enable Skills** in Advanced Settings. The agent will receive a list of available skills in its context and have access to skill tools (list, read, search, resources) via the built-in skills MCP.
|
||||
- Skills use the same format as [skillserver](https://github.com/mudler/skillserver) (e.g. `SKILL.md` in a directory). You can export skills from LocalAGI and use them with the standalone skillserver, or import skills created elsewhere.
|
||||
|
||||
In Docker, the state directory is persisted (`/pool`), so skills are stored in `/pool/skills`. To use a host folder for skills, mount it over that path in your compose file (e.g. `- ./my-skills:/pool/skills`).
|
||||
|
||||
### Development
|
||||
|
||||
The development workflow is similar to the source build, but with additional steps for hot reloading of the frontend:
|
||||
@@ -746,7 +727,7 @@ 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
|
||||
# Knowledge base is built-in; no separate LocalRecall service needed
|
||||
export LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
export LOCALAGI_STATE_DIR=./pool
|
||||
export LOCALAGI_TIMEOUT=5m
|
||||
export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
@@ -1050,8 +1031,7 @@ LocalAGI supports environment configurations. Note that these environment variab
|
||||
| `LOCALAGI_LLM_API_KEY` | API authentication |
|
||||
| `LOCALAGI_TIMEOUT` | Request timeout settings |
|
||||
| `LOCALAGI_STATE_DIR` | Where state gets stored |
|
||||
| `LOCALAGI_LOCALRAG_URL` | Optional URL when using an external LocalRAG URL; not used for built-in knowledge base |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for the app (defaults to ":3000") |
|
||||
| `LOCALAGI_LOCALRAG_URL` | LocalRecall connection |
|
||||
| `LOCALAGI_SSHBOX_URL` | LocalAGI SSHBox URL, e.g. user:pass@ip:port |
|
||||
| `LOCALAGI_ENABLE_CONVERSATIONS_LOGGING` | Toggle conversation logs |
|
||||
| `LOCALAGI_API_KEYS` | A comma separated list of api keys used for authentication |
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var agentCmd = &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Manage agents",
|
||||
Long: "Commands for managing and running LocalAGI agents.",
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentCmd.AddCommand(agentRunCmd)
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
configFile string
|
||||
prompt string
|
||||
)
|
||||
|
||||
var agentRunCmd = &cobra.Command{
|
||||
Use: "run [agent_name]",
|
||||
Short: "Run an agent standalone",
|
||||
Long: `Run an agent without starting the web server.
|
||||
|
||||
Two modes are supported:
|
||||
1. Run an agent by name from the registry (pool.json):
|
||||
local-agi agent run my-agent
|
||||
2. Run an agent from a JSON config file:
|
||||
local-agi agent run --config agent.json
|
||||
3. Run an agent in foreground mode with a prompt:
|
||||
local-agi agent run my-agent --prompt "Your question here"
|
||||
|
||||
The agent runs in the foreground until interrupted (Ctrl+C).`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runAgent,
|
||||
}
|
||||
|
||||
func init() {
|
||||
agentRunCmd.Flags().StringVarP(&configFile, "config", "c", "", "path to agent JSON config file")
|
||||
agentRunCmd.Flags().StringVarP(&prompt, "prompt", "p", "", "run in foreground mode with the given prompt and exit after response")
|
||||
}
|
||||
|
||||
func runAgent(cmd *cobra.Command, args []string) error {
|
||||
agentName, agentConfig, err := resolveAgentConfig(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If --prompt is provided, run in foreground mode
|
||||
if prompt != "" {
|
||||
return runAgentForeground(agentName, agentConfig, prompt)
|
||||
}
|
||||
|
||||
return startStandaloneAgent(agentName, agentConfig)
|
||||
}
|
||||
|
||||
// runAgentForeground runs an agent in foreground mode with a single prompt,
|
||||
// prints the response, and exits.
|
||||
func runAgentForeground(agentName string, agentConfig *state.AgentConfig, promptText string) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
env.Model = agentConfig.Model
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
env.LLMAPIURL = agentConfig.APIURL
|
||||
}
|
||||
if env.LLMAPIKey == "" {
|
||||
env.LLMAPIKey = agentConfig.APIKey
|
||||
}
|
||||
|
||||
if env.Model == "" {
|
||||
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
// Override config with resolved values
|
||||
agentConfig.Model = env.Model
|
||||
agentConfig.APIURL = env.LLMAPIURL
|
||||
agentConfig.APIKey = env.LLMAPIKey
|
||||
agentConfig.MultimodalModel = env.MultimodalModel
|
||||
agentConfig.TranscriptionModel = env.TranscriptionModel
|
||||
agentConfig.TranscriptionLanguage = env.TranscriptionLanguage
|
||||
agentConfig.TTSModel = env.TTSModel
|
||||
|
||||
// Initialize skills service
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize skills service: %w", err)
|
||||
}
|
||||
|
||||
// Build service factories
|
||||
actionsFactory := services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
|
||||
// Create the pool
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
|
||||
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
|
||||
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
|
||||
env.Timeout, false, skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent pool: %w", err)
|
||||
}
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
}
|
||||
|
||||
// Start the agent
|
||||
if err := pool.StartAgentStandalone(agentName, agentConfig); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
}
|
||||
|
||||
a := pool.GetAgent(agentName)
|
||||
if a == nil {
|
||||
return fmt.Errorf("agent %q was not found after starting", agentName)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Running agent %q in foreground mode with prompt...\n", agentName)
|
||||
|
||||
// Execute Ask with the prompt using WithText option
|
||||
result := a.Ask(types.WithText(promptText))
|
||||
|
||||
// Print the result
|
||||
if result.Error != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %s\n", result.Error.Error())
|
||||
pool.Stop(agentName)
|
||||
return fmt.Errorf("agent error: %s", result.Error.Error())
|
||||
}
|
||||
|
||||
// Print the response
|
||||
fmt.Println(result.Response)
|
||||
|
||||
// Clean up
|
||||
pool.Stop(agentName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAgentConfig determines the agent name and config from either
|
||||
// a registry lookup or a JSON config file.
|
||||
func resolveAgentConfig(args []string) (string, *state.AgentConfig, error) {
|
||||
if configFile != "" && len(args) > 0 {
|
||||
return "", nil, fmt.Errorf("cannot specify both --config and agent name; use one or the other")
|
||||
}
|
||||
|
||||
if configFile == "" && len(args) == 0 {
|
||||
return "", nil, fmt.Errorf("either an agent name or --config <file> is required")
|
||||
}
|
||||
|
||||
if configFile != "" {
|
||||
return loadConfigFromFile(configFile)
|
||||
}
|
||||
|
||||
return loadConfigFromRegistry(args[0])
|
||||
}
|
||||
|
||||
// loadConfigFromFile reads and validates an agent config from a JSON file.
|
||||
func loadConfigFromFile(path string) (string, *state.AgentConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var config state.AgentConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validateConfig(&config); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid config in %q: %w", path, err)
|
||||
}
|
||||
|
||||
name := config.Name
|
||||
if name == "" {
|
||||
// Derive name from filename
|
||||
base := filepath.Base(path)
|
||||
name = base[:len(base)-len(filepath.Ext(base))]
|
||||
config.Name = name
|
||||
}
|
||||
|
||||
return name, &config, nil
|
||||
}
|
||||
|
||||
// loadConfigFromRegistry loads an agent config from the pool.json registry.
|
||||
func loadConfigFromRegistry(name string) (string, *state.AgentConfig, error) {
|
||||
stateDir := os.Getenv("LOCALAGI_STATE_DIR")
|
||||
if stateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
stateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
|
||||
poolFile := filepath.Join(stateDir, "pool.json")
|
||||
data, err := os.ReadFile(poolFile)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to read pool file %q: %w\nEnsure LOCALAGI_STATE_DIR is set or a pool/ directory exists", poolFile, err)
|
||||
}
|
||||
|
||||
var pool map[string]state.AgentConfig
|
||||
if err := json.Unmarshal(data, &pool); err != nil {
|
||||
return "", nil, fmt.Errorf("failed to parse pool file %q: %w", poolFile, err)
|
||||
}
|
||||
|
||||
config, exists := pool[name]
|
||||
if !exists {
|
||||
available := make([]string, 0, len(pool))
|
||||
for k := range pool {
|
||||
available = append(available, k)
|
||||
}
|
||||
return "", nil, fmt.Errorf("agent %q not found in registry\nAvailable agents: %v", name, available)
|
||||
}
|
||||
|
||||
return name, &config, nil
|
||||
}
|
||||
|
||||
// validateConfig checks that required fields are present in the config.
|
||||
func validateConfig(config *state.AgentConfig) error {
|
||||
// Model and API URL can come from env vars, so they're not strictly required in config.
|
||||
// But we validate that the config is at least parseable (already done by JSON unmarshal).
|
||||
return nil
|
||||
}
|
||||
|
||||
// startStandaloneAgent creates and runs a single agent using the pool,
|
||||
// without starting the web server.
|
||||
func startStandaloneAgent(name string, config *state.AgentConfig) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
env.Model = config.Model
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
env.LLMAPIURL = config.APIURL
|
||||
}
|
||||
if env.LLMAPIKey == "" {
|
||||
env.LLMAPIKey = config.APIKey
|
||||
}
|
||||
|
||||
if env.Model == "" {
|
||||
return fmt.Errorf("model not set: provide 'model' in config or set LOCALAGI_MODEL")
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return fmt.Errorf("API URL not set: provide 'api_url' in config or set LOCALAGI_LLM_API_URL")
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get working directory: %w", err)
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
// Override config with resolved values
|
||||
config.Model = env.Model
|
||||
config.APIURL = env.LLMAPIURL
|
||||
config.APIKey = env.LLMAPIKey
|
||||
config.MultimodalModel = env.MultimodalModel
|
||||
config.TranscriptionModel = env.TranscriptionModel
|
||||
config.TranscriptionLanguage = env.TranscriptionLanguage
|
||||
config.TTSModel = env.TTSModel
|
||||
|
||||
if config.PeriodicRuns == "" {
|
||||
config.PeriodicRuns = "10m"
|
||||
}
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// Initialize skills service
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize skills service: %w", err)
|
||||
}
|
||||
|
||||
// Build service factories
|
||||
actionsFactory := services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
dynamicPromptsFactory := services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
})
|
||||
|
||||
// Create the pool and use it to start the agent
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model, env.MultimodalModel, env.TranscriptionModel, env.TranscriptionLanguage, env.TTSModel,
|
||||
env.LLMAPIURL, env.LLMAPIKey, env.StateDir,
|
||||
actionsFactory, services.Connectors, dynamicPromptsFactory, services.Filters,
|
||||
env.Timeout, false, skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create agent pool: %w", err)
|
||||
}
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
}
|
||||
|
||||
// Start the agent via the pool (handles all option building, connectors, etc.)
|
||||
if err := pool.StartAgentStandalone(name, config); err != nil {
|
||||
return fmt.Errorf("failed to start agent: %w", err)
|
||||
}
|
||||
|
||||
a := pool.GetAgent(name)
|
||||
if a == nil {
|
||||
return fmt.Errorf("agent %q was not found after starting", name)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Starting agent %q (model: %s, api: %s)\n", name, env.Model, env.LLMAPIURL)
|
||||
fmt.Fprintf(os.Stderr, "Press Ctrl+C to stop\n")
|
||||
|
||||
// Wait for interrupt
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
sig := <-sigCh
|
||||
fmt.Fprintf(os.Stderr, "\nReceived %v, stopping agent...\n", sig)
|
||||
pool.Stop(name)
|
||||
|
||||
// Give agent a moment to clean up
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Env contains all environment variables used by LocalAGI
|
||||
type Env struct {
|
||||
// Model and API configuration
|
||||
Model string
|
||||
LLMAPIURL string
|
||||
LLMAPIKey string
|
||||
MultimodalModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
Timeout string
|
||||
|
||||
// Directories and paths
|
||||
StateDir string
|
||||
LocalAGIURL string
|
||||
LocalRAGURL string
|
||||
CustomActionsDir string
|
||||
SSHBoxURL string
|
||||
CollectionDBPath string
|
||||
FileAssets string
|
||||
|
||||
// Conversation settings
|
||||
EnableConversationsLogging bool
|
||||
APIKeys []string
|
||||
ConversationDuration string
|
||||
|
||||
// RAG/Vector settings
|
||||
VectorEngine string
|
||||
EmbeddingModel string
|
||||
MaxChunkingSize int
|
||||
ChunkOverlap int
|
||||
DatabaseURL string
|
||||
}
|
||||
|
||||
// LoadEnv reads all environment variables and returns an Env struct
|
||||
func LoadEnv() Env {
|
||||
env := Env{
|
||||
Model: envOrDefault("LOCALAGI_MODEL", ""),
|
||||
LLMAPIURL: envOrDefault("LOCALAGI_LLM_API_URL", ""),
|
||||
LLMAPIKey: envOrDefault("LOCALAGI_LLM_API_KEY", ""),
|
||||
MultimodalModel: envOrDefault("LOCALAGI_MULTIMODAL_MODEL", ""),
|
||||
TranscriptionModel: envOrDefault("LOCALAGI_TRANSCRIPTION_MODEL", ""),
|
||||
TranscriptionLanguage: envOrDefault("LOCALAGI_TRANSCRIPTION_LANGUAGE", ""),
|
||||
TTSModel: envOrDefault("LOCALAGI_TTS_MODEL", ""),
|
||||
Timeout: envOrDefault("LOCALAGI_TIMEOUT", "5m"),
|
||||
StateDir: envOrDefault("LOCALAGI_STATE_DIR", ""),
|
||||
LocalAGIURL: envOrDefault("LOCALAGI_BASE_URL", ":3000"),
|
||||
LocalRAGURL: os.Getenv("LOCALAGI_LOCALRAG_URL"),
|
||||
CustomActionsDir: os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR"),
|
||||
SSHBoxURL: os.Getenv("LOCALAGI_SSHBOX_URL"),
|
||||
EnableConversationsLogging: os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true",
|
||||
ConversationDuration: os.Getenv("LOCALAGI_CONVERSATION_DURATION"),
|
||||
CollectionDBPath: os.Getenv("COLLECTION_DB_PATH"),
|
||||
FileAssets: os.Getenv("FILE_ASSETS"),
|
||||
VectorEngine: os.Getenv("VECTOR_ENGINE"),
|
||||
EmbeddingModel: os.Getenv("EMBEDDING_MODEL"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
}
|
||||
|
||||
// Parse APIKeys from comma-separated string
|
||||
if apiKeysEnv := os.Getenv("LOCALAGI_API_KEYS"); apiKeysEnv != "" {
|
||||
env.APIKeys = strings.Split(apiKeysEnv, ",")
|
||||
}
|
||||
|
||||
// Parse numeric values
|
||||
if maxChunkingSizeEnv := os.Getenv("MAX_CHUNKING_SIZE"); maxChunkingSizeEnv != "" {
|
||||
if n, err := strconv.Atoi(maxChunkingSizeEnv); err == nil {
|
||||
env.MaxChunkingSize = n
|
||||
}
|
||||
}
|
||||
|
||||
if chunkOverlapEnv := os.Getenv("CHUNK_OVERLAP"); chunkOverlapEnv != "" {
|
||||
if n, err := strconv.Atoi(chunkOverlapEnv); err == nil {
|
||||
env.ChunkOverlap = n
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults for empty values
|
||||
if env.VectorEngine == "" {
|
||||
env.VectorEngine = "chromem"
|
||||
}
|
||||
if env.EmbeddingModel == "" {
|
||||
env.EmbeddingModel = "granite-embedding-107m-multilingual"
|
||||
}
|
||||
if env.MaxChunkingSize == 0 {
|
||||
env.MaxChunkingSize = 400
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
// envOrDefault returns the environment variable value if set, otherwise the fallback.
|
||||
func envOrDefault(envKey, fallback string) string {
|
||||
if v := os.Getenv(envKey); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "local-agi",
|
||||
Short: "LocalAGI - Self-hosted AI Agent platform",
|
||||
Long: "LocalAGI is a self-hosted AI Agent platform that allows running autonomous agents with various connectors, actions, and tools.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// If no subcommand is provided, default to serving the web server
|
||||
// This ensures the container starts the web server by default
|
||||
return serveCmd.RunE(cmd, args)
|
||||
},
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(agentCmd)
|
||||
}
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
"github.com/mudler/LocalAGI/webui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the LocalAGI web server",
|
||||
Long: "Start the LocalAGI web server with the agent pool and web UI.",
|
||||
RunE: runServe,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
}
|
||||
|
||||
func runServe(cmd *cobra.Command, args []string) error {
|
||||
// Load all environment variables
|
||||
env := LoadEnv()
|
||||
|
||||
if env.Model == "" {
|
||||
return cmd.Help()
|
||||
}
|
||||
if env.LLMAPIURL == "" {
|
||||
return cmd.Help()
|
||||
}
|
||||
|
||||
if env.StateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env.StateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
|
||||
os.MkdirAll(env.StateDir, 0755)
|
||||
|
||||
if env.CollectionDBPath == "" {
|
||||
env.CollectionDBPath = filepath.Join(env.StateDir, "collections")
|
||||
}
|
||||
if env.FileAssets == "" {
|
||||
env.FileAssets = filepath.Join(env.StateDir, "assets")
|
||||
}
|
||||
|
||||
apiKeys := env.APIKeys
|
||||
if len(apiKeys) == 0 {
|
||||
apiKeys = []string{}
|
||||
}
|
||||
|
||||
skillsService, err := skills.NewService(env.StateDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pool, err := state.NewAgentPool(
|
||||
env.Model,
|
||||
env.MultimodalModel,
|
||||
env.TranscriptionModel,
|
||||
env.TranscriptionLanguage,
|
||||
env.TTSModel,
|
||||
env.LLMAPIURL,
|
||||
env.LLMAPIKey,
|
||||
env.StateDir,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigSSHBoxURL: env.SSHBoxURL,
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: env.StateDir,
|
||||
services.CustomActionsDir: env.CustomActionsDir,
|
||||
}),
|
||||
services.Filters,
|
||||
env.Timeout,
|
||||
env.EnableConversationsLogging,
|
||||
skillsService,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app := webui.NewApp(
|
||||
webui.WithPool(pool),
|
||||
webui.WithSkillsService(skillsService),
|
||||
webui.WithConversationStoreduration(env.ConversationDuration),
|
||||
webui.WithApiKeys(apiKeys...),
|
||||
webui.WithLLMAPIUrl(env.LLMAPIURL),
|
||||
webui.WithLLMAPIKey(env.LLMAPIKey),
|
||||
webui.WithLLMModel(env.Model),
|
||||
webui.WithCustomActionsDir(env.CustomActionsDir),
|
||||
webui.WithStateDir(env.StateDir),
|
||||
webui.WithCollectionDBPath(env.CollectionDBPath),
|
||||
webui.WithFileAssets(env.FileAssets),
|
||||
webui.WithVectorEngine(env.VectorEngine),
|
||||
webui.WithEmbeddingModel(env.EmbeddingModel),
|
||||
webui.WithMaxChunkingSize(env.MaxChunkingSize),
|
||||
webui.WithChunkOverlap(env.ChunkOverlap),
|
||||
webui.WithDatabaseURL(env.DatabaseURL),
|
||||
webui.WithLocalRAGURL(env.LocalRAGURL),
|
||||
)
|
||||
|
||||
if env.LocalRAGURL != "" {
|
||||
pool.SetRAGProvider(state.NewHTTPRAGProvider(env.LocalRAGURL, env.LLMAPIKey))
|
||||
} else {
|
||||
embedded := app.CollectionsRAGProvider()
|
||||
pool.SetRAGProvider(func(collectionName, _, _ string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return embedded(collectionName)
|
||||
})
|
||||
}
|
||||
|
||||
if err := pool.StartAll(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Fatal(app.Listen(env.LocalAGIURL))
|
||||
return nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
const ConversationActionName = "send_message"
|
||||
const ConversationActionName = "new_conversation"
|
||||
|
||||
func NewConversation() *ConversationAction {
|
||||
return &ConversationAction{}
|
||||
|
||||
+52
-127
@@ -6,24 +6,19 @@ 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 (
|
||||
RecurringReminderActionName = "set_recurring_task"
|
||||
OneTimeReminderActionName = "set_onetime_task"
|
||||
ListRemindersName = "list_tasks"
|
||||
RemoveReminderName = "remove_task"
|
||||
ReminderActionName = "set_reminder"
|
||||
ListRemindersName = "list_reminders"
|
||||
RemoveReminderName = "remove_reminder"
|
||||
)
|
||||
|
||||
func NewRecurringReminder() *RecurringReminderAction {
|
||||
return &RecurringReminderAction{}
|
||||
}
|
||||
|
||||
func NewOneTimeReminder() *OneTimeReminderAction {
|
||||
return &OneTimeReminderAction{}
|
||||
func NewReminder() *ReminderAction {
|
||||
return &ReminderAction{}
|
||||
}
|
||||
|
||||
func NewListReminders() *ListRemindersAction {
|
||||
@@ -34,8 +29,7 @@ func NewRemoveReminder() *RemoveReminderAction {
|
||||
return &RemoveReminderAction{}
|
||||
}
|
||||
|
||||
type RecurringReminderAction struct{}
|
||||
type OneTimeReminderAction struct{}
|
||||
type ReminderAction struct{}
|
||||
type ListRemindersAction struct{}
|
||||
type RemoveReminderAction struct{}
|
||||
|
||||
@@ -43,87 +37,46 @@ type RemoveReminderParams struct {
|
||||
Index int `json:"index"`
|
||||
}
|
||||
|
||||
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.RecurringReminderParams{}
|
||||
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
result := types.ReminderActionResponse{}
|
||||
err := params.Unmarshal(&result)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
task, err := scheduler.NewTask(
|
||||
sharedState.AgentName,
|
||||
result.Message,
|
||||
scheduler.ScheduleTypeCron,
|
||||
result.CronExpr,
|
||||
)
|
||||
// Validate the cron expression
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
_, err = parser.Parse(result.CronExpr)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
task.Metadata["reminder_type"] = "user_created"
|
||||
// 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)
|
||||
|
||||
err = sharedState.Scheduler.CreateTask(task)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
// 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)
|
||||
}
|
||||
sharedState.Reminders = append(sharedState.Reminders, result)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
|
||||
Result: "Reminder set successfully",
|
||||
Metadata: map[string]interface{}{
|
||||
"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,
|
||||
"reminder": result,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders set",
|
||||
}, nil
|
||||
@@ -131,25 +84,22 @@ func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentS
|
||||
|
||||
var result strings.Builder
|
||||
result.WriteString("Current reminders:\n")
|
||||
|
||||
for i, task := range tasks {
|
||||
for i, reminder := range sharedState.Reminders {
|
||||
status := "one-time"
|
||||
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
|
||||
if reminder.IsRecurring {
|
||||
status = "recurring"
|
||||
}
|
||||
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
|
||||
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
|
||||
i+1,
|
||||
task.Prompt,
|
||||
task.NextRun.Format(time.RFC3339),
|
||||
status,
|
||||
task.ID))
|
||||
reminder.Message,
|
||||
reminder.NextRun.Format(time.RFC3339),
|
||||
status))
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: result.String(),
|
||||
Metadata: map[string]interface{}{
|
||||
"tasks": tasks,
|
||||
"reminders": sharedState.Reminders,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -161,12 +111,7 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
tasks, err := sharedState.Scheduler.GetAllTasks()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
|
||||
return types.ActionResult{
|
||||
Result: "No reminders to remove",
|
||||
}, nil
|
||||
@@ -174,29 +119,23 @@ 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(tasks) {
|
||||
if index < 0 || index >= len(sharedState.Reminders) {
|
||||
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
|
||||
}
|
||||
|
||||
task := tasks[index]
|
||||
err = sharedState.Scheduler.DeleteTask(task.ID)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
// Remove the reminder
|
||||
removed := sharedState.Reminders[index]
|
||||
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)
|
||||
|
||||
return types.ActionResult{
|
||||
Result: fmt.Sprintf("Removed reminder: %s", task.Prompt),
|
||||
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
|
||||
Metadata: map[string]interface{}{
|
||||
"removed_task_id": task.ID,
|
||||
"removed_reminder": removed,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *RecurringReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *OneTimeReminderAction) Plannable() bool {
|
||||
func (a *ReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -208,10 +147,10 @@ func (a *RemoveReminderAction) Plannable() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
|
||||
func (a *ReminderAction) Definition() types.ActionDefinition {
|
||||
return types.ActionDefinition{
|
||||
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)",
|
||||
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)",
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"message": {
|
||||
Type: jsonschema.String,
|
||||
@@ -219,28 +158,14 @@ func (a *RecurringReminderAction) Definition() types.ActionDefinition {
|
||||
},
|
||||
"cron_expr": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
|
||||
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)",
|
||||
},
|
||||
},
|
||||
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"},
|
||||
Required: []string{"message", "cron_expr", "is_recurring"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-42
@@ -3,13 +3,12 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
@@ -92,47 +91,10 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeLeadingSystemMessages replaces all leading system messages with a single
|
||||
// system message. prefixBlocks are prepended in order (e.g. self-eval, then HUD).
|
||||
// Only non-empty prefixBlocks are joined. Mid-conversation system messages are unchanged.
|
||||
func (conv Messages) mergeLeadingSystemMessages(prefixBlocks ...string) Messages {
|
||||
var leading []string
|
||||
for _, s := range prefixBlocks {
|
||||
if s != "" {
|
||||
leading = append(leading, s)
|
||||
}
|
||||
}
|
||||
i := 0
|
||||
for i < len(conv) && conv[i].Role == SystemRole {
|
||||
content := conv[i].Content
|
||||
if content == "" && conv[i].MultiContent != nil {
|
||||
for _, part := range conv[i].MultiContent {
|
||||
if part.Type == openai.ChatMessagePartTypeText && part.Text != "" {
|
||||
content = part.Text
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if content != "" {
|
||||
leading = append(leading, content)
|
||||
}
|
||||
i++
|
||||
}
|
||||
if len(leading) == 0 {
|
||||
return conv
|
||||
}
|
||||
combined := strings.Join(leading, "\n\n")
|
||||
single := openai.ChatCompletionMessage{
|
||||
Role: SystemRole,
|
||||
Content: combined,
|
||||
}
|
||||
return append([]openai.ChatCompletionMessage{single}, conv[i:]...)
|
||||
}
|
||||
|
||||
// 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(job)
|
||||
baseActions := a.availableActions()
|
||||
|
||||
// Add user-defined actions from the job
|
||||
userTools := job.GetUserTools()
|
||||
@@ -145,11 +107,12 @@ func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
|
||||
return baseActions
|
||||
}
|
||||
|
||||
func (a *Agent) availableActions(j *types.Job) types.Actions {
|
||||
func (a *Agent) availableActions() types.Actions {
|
||||
// defaultActions := append(a.options.userActions, action.NewReply())
|
||||
|
||||
defaultActions := slices.Clone(a.options.userActions)
|
||||
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
|
||||
|
||||
if a.options.initiateConversations && a.selfEvaluationInProgress { // && self-evaluation..
|
||||
acts := append(defaultActions, action.NewConversation())
|
||||
if a.options.enableHUD {
|
||||
acts = append(acts, action.NewState())
|
||||
|
||||
+187
-458
@@ -14,15 +14,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/mudler/cogito/clients"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/scheduler"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/llm"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
@@ -32,20 +30,6 @@ const (
|
||||
SystemRole = "system"
|
||||
)
|
||||
|
||||
// NoToolToCallArgs defines the arguments for the no_tool_to_call sink state tool
|
||||
type NoToolToCallArgs struct {
|
||||
Reasoning string `json:"reasoning" description:"The reasoning for why no tool is being called"`
|
||||
}
|
||||
|
||||
// NoToolToCallTool is a custom sink state tool that logs when no other tool is needed
|
||||
type NoToolToCallTool struct{}
|
||||
|
||||
// Run executes the no_tool_to_call tool and logs a message
|
||||
func (t NoToolToCallTool) Run(args NoToolToCallArgs) (string, any, error) {
|
||||
xlog.Info("No tool to call - agent decided no action was needed", "reasoning", args.Reasoning)
|
||||
return fmt.Sprintf("No action needed: %s", args.Reasoning), nil, nil
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
sync.Mutex
|
||||
options *options
|
||||
@@ -59,26 +43,19 @@ type Agent struct {
|
||||
selfEvaluationInProgress bool
|
||||
pause bool
|
||||
|
||||
newConversations chan *types.ConversationMessage
|
||||
newConversations chan openai.ChatCompletionMessage
|
||||
|
||||
mcpSessions []*mcp.ClientSession
|
||||
// only contains the MCP action definitions for observables
|
||||
mcpActionDefinitions types.Actions
|
||||
|
||||
subscriberMutex sync.Mutex
|
||||
newMessagesSubscribers []func(*types.ConversationMessage)
|
||||
newMessagesSubscribers []func(openai.ChatCompletionMessage)
|
||||
|
||||
observer Observer
|
||||
|
||||
llm cogito.LLM
|
||||
sharedState *types.AgentSharedState
|
||||
|
||||
// Task scheduler for managing reminders
|
||||
taskScheduler *scheduler.Scheduler
|
||||
|
||||
// currentJobByConversation tracks the running job per conversation_id for cancel-previous-on-new-message
|
||||
currentJobByConversation map[string]*types.Job
|
||||
currentJobMu sync.Mutex
|
||||
}
|
||||
|
||||
type RAGDB interface {
|
||||
@@ -95,7 +72,7 @@ func New(opts ...Option) (*Agent, error) {
|
||||
}
|
||||
|
||||
client := llm.NewClient(options.LLMAPI.APIKey, options.LLMAPI.APIURL, options.timeout)
|
||||
llmClient := clients.NewLocalAILLM(options.LLMAPI.Model, options.LLMAPI.APIKey, options.LLMAPI.APIURL)
|
||||
llmClient := cogito.NewOpenAILLM(options.LLMAPI.Model, options.LLMAPI.APIKey, options.LLMAPI.APIURL)
|
||||
c := context.Background()
|
||||
if options.context != nil {
|
||||
c = options.context
|
||||
@@ -103,17 +80,16 @@ func New(opts ...Option) (*Agent, error) {
|
||||
|
||||
ctx, cancel := context.WithCancel(c)
|
||||
a := &Agent{
|
||||
jobQueue: make(chan *types.Job),
|
||||
options: options,
|
||||
client: client,
|
||||
Character: options.character,
|
||||
currentState: &types.AgentInternalState{},
|
||||
llm: llmClient,
|
||||
context: types.NewActionContext(ctx, cancel),
|
||||
newConversations: make(chan *types.ConversationMessage),
|
||||
newMessagesSubscribers: options.newConversationsSubscribers,
|
||||
sharedState: types.NewAgentSharedState(options.lastMessageDuration),
|
||||
currentJobByConversation: make(map[string]*types.Job),
|
||||
jobQueue: make(chan *types.Job),
|
||||
options: options,
|
||||
client: client,
|
||||
Character: options.character,
|
||||
currentState: &types.AgentInternalState{},
|
||||
llm: llmClient,
|
||||
context: types.NewActionContext(ctx, cancel),
|
||||
newConversations: make(chan openai.ChatCompletionMessage),
|
||||
newMessagesSubscribers: options.newConversationsSubscribers,
|
||||
sharedState: types.NewAgentSharedState(options.lastMessageDuration),
|
||||
}
|
||||
|
||||
// Initialize observer if provided
|
||||
@@ -142,28 +118,6 @@ func New(opts ...Option) (*Agent, error) {
|
||||
a.initMCPActions()
|
||||
xlog.Info("Done populating actions from MCP Servers")
|
||||
|
||||
// Initialize task scheduler for reminders
|
||||
schedulerPath := options.schedulerStorePath
|
||||
if schedulerPath == "" {
|
||||
schedulerPath = "scheduled_tasks.json"
|
||||
}
|
||||
|
||||
store, err := scheduler.NewJSONStore(schedulerPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scheduler store: %v", err)
|
||||
}
|
||||
|
||||
executor := &agentSchedulerExecutor{agent: a}
|
||||
pollInterval := options.schedulerPollInterval
|
||||
if pollInterval == 0 {
|
||||
pollInterval = 30 * time.Second
|
||||
}
|
||||
|
||||
a.taskScheduler = scheduler.NewScheduler(store, executor, pollInterval)
|
||||
a.sharedState.Scheduler = a.taskScheduler
|
||||
a.sharedState.AgentName = a.Character.Name
|
||||
xlog.Info("Task scheduler initialized", "store_path", schedulerPath, "poll_interval", pollInterval)
|
||||
|
||||
xlog.Info(
|
||||
"Agent created",
|
||||
"agent", a.Character.Name,
|
||||
@@ -180,19 +134,6 @@ func (a *Agent) SharedState() *types.AgentSharedState {
|
||||
return a.sharedState
|
||||
}
|
||||
|
||||
// SetStreamCallback sets (or replaces) the stream callback on a live agent.
|
||||
// This allows callers to wire streaming events after agent creation,
|
||||
func (a *Agent) SetStreamCallback(fn func(cogito.StreamEvent)) {
|
||||
a.options.streamCallback = fn
|
||||
}
|
||||
|
||||
// StartConversationConsumer starts the goroutine that dispatches new conversation
|
||||
// messages to subscribers. This must be called when using AskDirect() without Run(),
|
||||
// otherwise the ConversationAction handler will deadlock on the newConversations channel.
|
||||
func (a *Agent) StartConversationConsumer() {
|
||||
a.startNewConversationsConsumer()
|
||||
}
|
||||
|
||||
func (a *Agent) startNewConversationsConsumer() {
|
||||
go func() {
|
||||
for {
|
||||
@@ -201,21 +142,19 @@ func (a *Agent) startNewConversationsConsumer() {
|
||||
return
|
||||
|
||||
case msg := <-a.newConversations:
|
||||
xlog.Debug("New conversation", "agent", a.Character.Name, "message", msg.Message.Content)
|
||||
xlog.Debug("New conversation", "agent", a.Character.Name, "message", msg.Content)
|
||||
a.subscriberMutex.Lock()
|
||||
subs := a.newMessagesSubscribers
|
||||
a.subscriberMutex.Unlock()
|
||||
for _, s := range subs {
|
||||
if s != nil && msg != nil {
|
||||
s(msg)
|
||||
}
|
||||
s(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *Agent) AddSubscriber(f func(*types.ConversationMessage)) {
|
||||
func (a *Agent) AddSubscriber(f func(openai.ChatCompletionMessage)) {
|
||||
a.subscriberMutex.Lock()
|
||||
defer a.subscriberMutex.Unlock()
|
||||
a.newMessagesSubscribers = append(a.newMessagesSubscribers, f)
|
||||
@@ -250,84 +189,6 @@ func (a *Agent) Ask(opts ...types.JobOption) *types.JobResult {
|
||||
))
|
||||
}
|
||||
|
||||
// AskDirect executes a job synchronously without requiring Run() to be active.
|
||||
// Unlike Ask/Execute which enqueue to the internal jobQueue (consumed by Run()),
|
||||
// AskDirect calls consumeJob directly. This enables stateless execution where
|
||||
// the caller manages the event loop
|
||||
func (a *Agent) AskDirect(opts ...types.JobOption) *types.JobResult {
|
||||
xlog.Debug("Agent AskDirect()", "agent", a.Character.Name, "model", a.options.LLMAPI.Model)
|
||||
defer func() {
|
||||
xlog.Debug("Agent AskDirect finished", "agent", a.Character.Name)
|
||||
}()
|
||||
|
||||
j := types.NewJob(
|
||||
append(
|
||||
opts,
|
||||
types.WithReasoningCallback(a.options.reasoningCallback),
|
||||
types.WithResultCallback(a.options.resultCallback),
|
||||
)...,
|
||||
)
|
||||
|
||||
if a.observer != nil {
|
||||
obs := a.observer.NewObservable()
|
||||
obs.Name = "job"
|
||||
obs.Icon = "plug"
|
||||
a.observer.Update(*obs)
|
||||
j.Obs = obs
|
||||
|
||||
if len(j.ConversationHistory) > 0 {
|
||||
m := j.ConversationHistory[len(j.ConversationHistory)-1]
|
||||
j.Obs.Creation = &types.Creation{ChatCompletionMessage: &m}
|
||||
a.observer.Update(*j.Obs)
|
||||
}
|
||||
|
||||
j.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
||||
if a.observer == nil {
|
||||
return
|
||||
}
|
||||
if j.Obs.Completion == nil {
|
||||
j.Obs.Completion = &types.Completion{}
|
||||
}
|
||||
j.Obs.Completion.Conversation = ccm
|
||||
if j.Result.Error != nil {
|
||||
j.Obs.Completion.Error = j.Result.Error.Error()
|
||||
}
|
||||
a.observer.Update(*j.Obs)
|
||||
})
|
||||
}
|
||||
|
||||
a.consumeJob(j, UserRole)
|
||||
return j.Result
|
||||
}
|
||||
|
||||
// AskDirectSystem is like AskDirect but executes with SystemRole,
|
||||
// used for periodic autonomous runs and scheduled tasks.
|
||||
func (a *Agent) AskDirectSystem(opts ...types.JobOption) *types.JobResult {
|
||||
xlog.Debug("Agent AskDirectSystem()", "agent", a.Character.Name)
|
||||
defer func() {
|
||||
xlog.Debug("Agent AskDirectSystem finished", "agent", a.Character.Name)
|
||||
}()
|
||||
|
||||
j := types.NewJob(
|
||||
append(
|
||||
opts,
|
||||
types.WithReasoningCallback(a.options.reasoningCallback),
|
||||
types.WithResultCallback(a.options.resultCallback),
|
||||
)...,
|
||||
)
|
||||
|
||||
if a.observer != nil {
|
||||
obs := a.observer.NewObservable()
|
||||
obs.Name = "standalone"
|
||||
obs.Icon = "clock"
|
||||
a.observer.Update(*obs)
|
||||
j.Obs = obs
|
||||
}
|
||||
|
||||
a.consumeJob(j, SystemRole)
|
||||
return j.Result
|
||||
}
|
||||
|
||||
// Ask is a pre-emptive, blocking call that returns the response as soon as it's ready.
|
||||
// It discards any other computation.
|
||||
func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
||||
@@ -336,7 +197,7 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
||||
xlog.Debug("Agent has finished", "agent", a.Character.Name)
|
||||
}()
|
||||
|
||||
if j.Obs != nil && a.observer != nil {
|
||||
if j.Obs != nil {
|
||||
if len(j.ConversationHistory) > 0 {
|
||||
m := j.ConversationHistory[len(j.ConversationHistory)-1]
|
||||
j.Obs.Creation = &types.Creation{ChatCompletionMessage: &m}
|
||||
@@ -344,46 +205,26 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
||||
}
|
||||
|
||||
j.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
||||
if a.observer == nil {
|
||||
return
|
||||
j.Obs.Completion = &types.Completion{
|
||||
Conversation: ccm,
|
||||
}
|
||||
// Merge into existing Completion so last-progress completion data is preserved
|
||||
if j.Obs.Completion == nil {
|
||||
j.Obs.Completion = &types.Completion{}
|
||||
}
|
||||
j.Obs.Completion.Conversation = ccm
|
||||
|
||||
if j.Result.Error != nil {
|
||||
j.Obs.Completion.Error = j.Result.Error.Error()
|
||||
}
|
||||
|
||||
a.observer.Update(*j.Obs)
|
||||
})
|
||||
}
|
||||
|
||||
a.Enqueue(j)
|
||||
result, err := j.Result.WaitResult(a.context.Context)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
return j.Result.WaitResult()
|
||||
}
|
||||
|
||||
func (a *Agent) Enqueue(j *types.Job) {
|
||||
j.ReasoningCallback = a.options.reasoningCallback
|
||||
j.ResultCallback = a.options.resultCallback
|
||||
|
||||
// Cancel previous running job for this conversation if option is enabled
|
||||
cancelPrevious := a.options.cancelPreviousOnNewMessage == nil || *a.options.cancelPreviousOnNewMessage
|
||||
if cancelPrevious && j.Metadata != nil {
|
||||
if convID, ok := j.Metadata[types.MetadataKeyConversationID].(string); ok && convID != "" {
|
||||
a.currentJobMu.Lock()
|
||||
existing := a.currentJobByConversation[convID]
|
||||
a.currentJobMu.Unlock()
|
||||
if existing != nil {
|
||||
existing.Cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.jobQueue <- j
|
||||
}
|
||||
|
||||
@@ -426,16 +267,10 @@ func (a *Agent) TTS(ctx context.Context, text string) ([]byte, error) {
|
||||
var ErrContextCanceled = fmt.Errorf("context canceled")
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
xlog.Debug("Stopping agent", "agent", a.Character.Name)
|
||||
|
||||
// Stop the scheduler
|
||||
a.taskScheduler.Stop()
|
||||
xlog.Info("Task scheduler stopped")
|
||||
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
|
||||
a.closeMCPServers()
|
||||
xlog.Debug("Stopping agent", "agent", a.Character.Name)
|
||||
a.closeMCPSTDIOServers()
|
||||
a.context.Cancel()
|
||||
}
|
||||
|
||||
@@ -483,7 +318,7 @@ func (a *Agent) processPrompts(ctx context.Context, conversation Messages) Messa
|
||||
xlog.Error("Error rendering template", "error", err)
|
||||
}
|
||||
|
||||
content, err = templateExecute(promptTemplate, CommonTemplateData{AgentName: a.Character.Name})
|
||||
content, err = templateExecute(promptTemplate, struct{}{})
|
||||
if err != nil {
|
||||
xlog.Error("Error executing template", "error", err)
|
||||
content = message.Content
|
||||
@@ -542,7 +377,7 @@ func (a *Agent) processPrompts(ctx context.Context, conversation Messages) Messa
|
||||
xlog.Error("Error rendering template", "error", err)
|
||||
}
|
||||
|
||||
content, err = templateExecute(promptTemplate, CommonTemplateData{AgentName: a.Character.Name})
|
||||
content, err = templateExecute(promptTemplate, struct{}{})
|
||||
if err != nil {
|
||||
xlog.Error("Error executing template", "error", err)
|
||||
content = a.options.systemPrompt
|
||||
@@ -651,19 +486,27 @@ func (a *Agent) processUserInputs(conv Messages) Messages {
|
||||
|
||||
// Add the text content as a new message with the same role first
|
||||
if text != "" {
|
||||
imageDesc := fmt.Sprintf("\n\n[Images in this message: %s]", strings.Join(imageDescriptions, "; "))
|
||||
textMessage := openai.ChatCompletionMessage{
|
||||
Role: message.Role,
|
||||
Content: text + imageDesc,
|
||||
Content: text,
|
||||
}
|
||||
processedMessages = append(processedMessages, textMessage)
|
||||
|
||||
// Add the image descriptions as a system message after the text
|
||||
explainerMessage := openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("The above message also contains %d image(s) which can be described as: %s",
|
||||
len(images), strings.Join(imageDescriptions, "; ")),
|
||||
}
|
||||
processedMessages = append(processedMessages, explainerMessage)
|
||||
} else {
|
||||
// Images only: emit a single user message with the image description
|
||||
content := fmt.Sprintf("[Attached images: %s]", strings.Join(imageDescriptions, "; "))
|
||||
processedMessages = append(processedMessages, openai.ChatCompletionMessage{
|
||||
Role: message.Role,
|
||||
Content: content,
|
||||
})
|
||||
// If there's no text, just add the image descriptions as a system message
|
||||
explainerMessage := openai.ChatCompletionMessage{
|
||||
Role: "system",
|
||||
Content: fmt.Sprintf("Message contains %d image(s) which can be described as: %s",
|
||||
len(images), strings.Join(imageDescriptions, "; ")),
|
||||
}
|
||||
processedMessages = append(processedMessages, explainerMessage)
|
||||
}
|
||||
} else {
|
||||
// No image found, keep the original message
|
||||
@@ -723,7 +566,7 @@ func (a *Agent) filterJob(job *types.Job) (ok bool, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if a.Observer() != nil && job.Obs != nil {
|
||||
if a.Observer() != nil {
|
||||
obs := a.Observer().NewObservable()
|
||||
obs.Name = "filter"
|
||||
obs.Icon = "shield"
|
||||
@@ -794,7 +637,7 @@ func (a *Agent) validateBuiltinTools(job *types.Job) {
|
||||
}
|
||||
|
||||
// Get available actions
|
||||
availableActions := a.availableActions(job)
|
||||
availableActions := a.availableActions()
|
||||
|
||||
for _, tool := range builtinTools {
|
||||
functionName := tool.Name
|
||||
@@ -911,26 +754,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
// Register this job as the current one for its conversation (for cancel-previous-on-new-message)
|
||||
var conversationID string
|
||||
if job.Metadata != nil {
|
||||
if cid, ok := job.Metadata[types.MetadataKeyConversationID].(string); ok && cid != "" {
|
||||
conversationID = cid
|
||||
a.currentJobMu.Lock()
|
||||
a.currentJobByConversation[conversationID] = job
|
||||
a.currentJobMu.Unlock()
|
||||
}
|
||||
}
|
||||
if conversationID != "" {
|
||||
defer func() {
|
||||
a.currentJobMu.Lock()
|
||||
if a.currentJobByConversation[conversationID] == job {
|
||||
delete(a.currentJobByConversation, conversationID)
|
||||
}
|
||||
a.currentJobMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
// We are self evaluating if we consume the job as a system role
|
||||
selfEvaluation := role == SystemRole
|
||||
|
||||
@@ -949,28 +772,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Ensure job observable has Creation and Completion for jobs that bypass Execute() (e.g. periodic, scheduler)
|
||||
if job.Obs != nil && a.observer != nil {
|
||||
if job.Obs.Creation == nil && len(job.ConversationHistory) > 0 {
|
||||
m := job.ConversationHistory[len(job.ConversationHistory)-1]
|
||||
job.Obs.Creation = &types.Creation{ChatCompletionMessage: &m}
|
||||
a.observer.Update(*job.Obs)
|
||||
}
|
||||
job.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
||||
if a.observer == nil {
|
||||
return
|
||||
}
|
||||
if job.Obs.Completion == nil {
|
||||
job.Obs.Completion = &types.Completion{}
|
||||
}
|
||||
job.Obs.Completion.Conversation = ccm
|
||||
if job.Result.Error != nil {
|
||||
job.Obs.Completion.Error = job.Result.Error.Error()
|
||||
}
|
||||
a.observer.Update(*job.Obs)
|
||||
})
|
||||
}
|
||||
|
||||
conv = a.processPrompts(job.GetContext(), conv)
|
||||
if ok, err := a.filterJob(job); !ok || err != nil {
|
||||
if err != nil {
|
||||
@@ -988,46 +789,38 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
// Validate builtin tools against available actions
|
||||
a.validateBuiltinTools(job)
|
||||
|
||||
// Merge all leading system messages into one (self-eval, HUD, RAG, system prompt, custom prompts)
|
||||
var selfEvalContent, hudContent string
|
||||
fragment := cogito.NewFragment(conv...)
|
||||
|
||||
if selfEvaluation {
|
||||
selfEvalContent = pickSelfTemplate
|
||||
fragment = fragment.AddStartMessage("system", pickSelfTemplate)
|
||||
}
|
||||
|
||||
if a.options.enableHUD {
|
||||
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(job), "")
|
||||
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(), "")
|
||||
if err != nil {
|
||||
job.Result.Finish(fmt.Errorf("error renderTemplate: %w", err))
|
||||
return
|
||||
}
|
||||
hudContent = prompt
|
||||
fragment = fragment.AddStartMessage("system", prompt)
|
||||
}
|
||||
conv = Messages(conv).mergeLeadingSystemMessages(selfEvalContent, hudContent)
|
||||
|
||||
// Backends with enable_thinking (e.g. vLLM) reject requests where the last message is
|
||||
// assistant (treated as "assistant response prefill"). We can end with assistant when:
|
||||
// - Web/API: client sends previous_response_id but no new input (ToChatCompletionMessages()
|
||||
// is empty), so messages = GetConversation(id) which was saved after the last reply and
|
||||
// ends with assistant.
|
||||
// - Connectors: if they pass a thread that was stored ending with assistant and no new
|
||||
// user message is appended in that code path.
|
||||
// - Periodic/scheduler jobs always use WithText(...) so they append a user message; they
|
||||
// do not end with assistant.
|
||||
// Normalize so we never send a request that ends with assistant (avoids enable_thinking
|
||||
// error); callers should ideally always append a new user message when continuing a thread.
|
||||
if len(conv) > 0 && conv[len(conv)-1].Role == AssistantRole {
|
||||
conv = append(conv, openai.ChatCompletionMessage{
|
||||
Role: UserRole,
|
||||
Content: " ",
|
||||
})
|
||||
}
|
||||
|
||||
fragment := cogito.NewFragment(conv...)
|
||||
|
||||
availableActions := a.getAvailableActionsForJob(job)
|
||||
cogitoTools := availableActions.ToCogitoTools(job.GetContext(), a.sharedState)
|
||||
allActions := append(availableActions, a.mcpActionDefinitions...)
|
||||
|
||||
obs := job.Obs
|
||||
if obs == nil && a.observer != nil && job.Obs != nil {
|
||||
obs = a.observer.NewObservable()
|
||||
obs.Name = "decision"
|
||||
obs.Icon = "brain"
|
||||
obs.ParentID = job.Obs.ID
|
||||
obs.Creation = &types.Creation{
|
||||
ChatCompletionRequest: &openai.ChatCompletionRequest{
|
||||
Model: a.options.LLMAPI.Model,
|
||||
Messages: conv,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if obs != nil && a.observer != nil {
|
||||
@@ -1038,37 +831,14 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
|
||||
var err error
|
||||
var userTool bool
|
||||
// Set by tool callback when it decides the job outcome; Finish is then called once after ExecuteTools.
|
||||
var finishedByCallback bool
|
||||
var finishErr error
|
||||
|
||||
var observables = make(map[string]*types.Observable)
|
||||
|
||||
cogitoOpts := []cogito.Option{
|
||||
cogito.WithMCPs(a.mcpSessions...),
|
||||
cogito.WithTools(
|
||||
cogitoTools...,
|
||||
),
|
||||
cogito.WithSinkState(
|
||||
cogito.NewToolDefinition(
|
||||
NoToolToCallTool{},
|
||||
NoToolToCallArgs{},
|
||||
"no_tool_to_call",
|
||||
"Called when no other tool is needed to respond to the user",
|
||||
),
|
||||
),
|
||||
cogito.WithReasoningCallback(func(s string) {
|
||||
xlog.Debug("Cogito reasoning callback", "status", s)
|
||||
if s == "" {
|
||||
return
|
||||
}
|
||||
// Forward reasoning to stream callback
|
||||
if a.options.streamCallback != nil {
|
||||
a.options.streamCallback(cogito.StreamEvent{
|
||||
Type: cogito.StreamEventReasoning,
|
||||
Content: s,
|
||||
})
|
||||
}
|
||||
|
||||
if a.observer != nil && job.Obs != nil {
|
||||
job.Obs.AddProgress(
|
||||
types.Progress{
|
||||
@@ -1085,46 +855,20 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
})
|
||||
a.observer.Update(*job.Obs)
|
||||
}
|
||||
job.Callback(types.ActionCurrentState{
|
||||
Job: job,
|
||||
Action: nil,
|
||||
Params: types.ActionParams{},
|
||||
Reasoning: s,
|
||||
})
|
||||
}),
|
||||
cogito.WithTools(
|
||||
cogitoTools...,
|
||||
),
|
||||
cogito.WithToolCallResultCallback(func(t cogito.ToolStatus) {
|
||||
toolObs := observables[t.ToolArguments.ID]
|
||||
if a.observer != nil && toolObs != nil {
|
||||
toolObs.Progress = append(toolObs.Progress, types.Progress{
|
||||
if a.observer != nil && obs != nil {
|
||||
obs := observables[t.ToolArguments.ID]
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
ActionResult: t.Result,
|
||||
})
|
||||
toolObs.Name = "action"
|
||||
toolObs.Icon = "bolt"
|
||||
toolObs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*toolObs)
|
||||
}
|
||||
|
||||
// Use full ActionResult (including Metadata) from action result,
|
||||
// so connectors receive e.g. songs_paths, images_url for sending files.
|
||||
actionResult := &types.ActionResult{
|
||||
Result: t.Result,
|
||||
}
|
||||
if t.ResultData != nil {
|
||||
switch res := t.ResultData.(type) {
|
||||
case types.ActionResult:
|
||||
actionResult = &res
|
||||
}
|
||||
}
|
||||
|
||||
// Merge action metadata into job metadata so it accumulates across actions
|
||||
// and is available when ConversationAction runs
|
||||
if actionResult.Metadata != nil {
|
||||
if job.Metadata == nil {
|
||||
job.Metadata = make(map[string]interface{})
|
||||
}
|
||||
for key, value := range actionResult.Metadata {
|
||||
job.Metadata[key] = value
|
||||
}
|
||||
obs.Name = "action"
|
||||
obs.Icon = "bolt"
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
aa := allActions.Find(t.Name)
|
||||
@@ -1135,14 +879,14 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
Params: types.ActionParams(t.ToolArguments.Arguments),
|
||||
Reasoning: t.ToolArguments.Reasoning,
|
||||
},
|
||||
ActionResult: *actionResult,
|
||||
ActionResult: types.ActionResult{Result: t.Result},
|
||||
}
|
||||
job.Result.SetResult(state)
|
||||
job.CallbackWithResult(state)
|
||||
conv = a.addFunctionResultToConversation(job.GetContext(), aa, types.ActionParams(t.ToolArguments.Arguments), *actionResult, conv)
|
||||
conv = a.addFunctionResultToConversation(job.GetContext(), aa, types.ActionParams(t.ToolArguments.Arguments), types.ActionResult{Result: t.Result}, conv)
|
||||
}),
|
||||
cogito.WithToolCallBack(
|
||||
func(tc *cogito.ToolChoice, _ *cogito.SessionState) cogito.ToolCallDecision {
|
||||
func(tc *cogito.ToolChoice) bool {
|
||||
|
||||
xlog.Debug("Tool call back", "tool_call", tc)
|
||||
|
||||
@@ -1155,22 +899,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
xlog.Debug("User-defined action chosen, returning tool call", "action", chosenAction.Definition().Name)
|
||||
a.replyWithToolCall(job, conv, tc.Arguments, chosenAction, tc.Reasoning)
|
||||
userTool = true
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Forward tool selection to stream callback
|
||||
if a.options.streamCallback != nil {
|
||||
toolName := tc.Name
|
||||
if chosenAction != nil {
|
||||
toolName = chosenAction.Definition().Name.String()
|
||||
}
|
||||
a.options.streamCallback(cogito.StreamEvent{
|
||||
Type: cogito.StreamEventToolCall,
|
||||
ToolName: toolName,
|
||||
ToolArgs: fmt.Sprintf("%v", tc.Arguments),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if a.observer != nil && job.Obs != nil {
|
||||
@@ -1193,19 +922,14 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
|
||||
switch tc.Name {
|
||||
case action.StopActionName:
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
return false
|
||||
case action.ConversationActionName:
|
||||
message := action.ConversationActionResponse{}
|
||||
toolArgs, _ := json.Marshal(tc.Arguments)
|
||||
if err := json.Unmarshal([]byte(toolArgs), &message); err != nil {
|
||||
xlog.Error("Error unmarshalling conversation response", "error", err)
|
||||
finishedByCallback = true
|
||||
finishErr = fmt.Errorf("error unmarshalling conversation response: %w", err)
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
|
||||
return false
|
||||
}
|
||||
|
||||
msg := openai.ChatCompletionMessage{
|
||||
@@ -1213,73 +937,54 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
Content: message.Message,
|
||||
}
|
||||
|
||||
// Get accumulated metadata from job (e.g., images, files generated by previous actions in this job)
|
||||
// This is per-job metadata, so parallel jobs won't interfere with each other
|
||||
metadata := job.Metadata
|
||||
|
||||
go func(agent *Agent) {
|
||||
xlog.Info("Sending new conversation to channel", "agent", agent.Character.Name, "message", msg.Content, "metadata_keys", len(metadata))
|
||||
// Send ConversationMessage with both the message and accumulated metadata
|
||||
agent.newConversations <- types.NewConversationMessage(msg).WithMetadata(metadata)
|
||||
// Job metadata is automatically cleared when job finishes, no need to manually clear
|
||||
xlog.Info("Sending new conversation to channel", "agent", agent.Character.Name, "message", msg.Content)
|
||||
agent.newConversations <- msg
|
||||
}(a)
|
||||
|
||||
job.Result.Conversation = []openai.ChatCompletionMessage{
|
||||
msg,
|
||||
}
|
||||
job.Result.SetResponse("decided to initiate a new conversation")
|
||||
finishedByCallback = true
|
||||
finishErr = nil
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
job.Result.Finish(nil)
|
||||
return true
|
||||
case action.StateActionName:
|
||||
// We need to store the result in the state
|
||||
state := types.AgentInternalState{}
|
||||
dat, _ := json.Marshal(tc.Arguments)
|
||||
err = json.Unmarshal(dat, &state)
|
||||
stateObs := observables[tc.ID]
|
||||
if err != nil {
|
||||
werr := fmt.Errorf("error unmarshalling state of the agent: %w", err)
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Completion = &types.Completion{
|
||||
if obs != nil && a.observer != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
Error: werr.Error(),
|
||||
}
|
||||
a.observer.Update(*stateObs)
|
||||
}
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// update the current state with the one we just got from the action
|
||||
a.currentState = &state
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Progress = append(stateObs.Progress, types.Progress{
|
||||
if obs != nil && a.observer != nil {
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
AgentState: &state,
|
||||
})
|
||||
a.observer.Update(*stateObs)
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
// update the state file
|
||||
if a.options.statefile != "" {
|
||||
if err := a.SaveState(a.options.statefile); err != nil {
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Completion = &types.Completion{
|
||||
if obs != nil && a.observer != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
Error: err.Error(),
|
||||
}
|
||||
a.observer.Update(*stateObs)
|
||||
a.observer.Update(*obs)
|
||||
}
|
||||
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Mark state tool-call observable as completed successfully
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*stateObs)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1302,12 +1007,10 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
})
|
||||
|
||||
job.Result.Conversation = conv
|
||||
finishedByCallback = true
|
||||
finishErr = nil
|
||||
}
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: cont,
|
||||
job.Result.Finish(nil)
|
||||
|
||||
}
|
||||
return cont
|
||||
},
|
||||
),
|
||||
}
|
||||
@@ -1317,54 +1020,19 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
if a.options.enableEvaluation {
|
||||
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlanReEvaluator)
|
||||
}
|
||||
if a.options.LLMAPI.ReviewerModel != "" {
|
||||
llmClient := clients.NewLocalAILLM(a.options.LLMAPI.ReviewerModel, a.options.LLMAPI.APIKey, a.options.LLMAPI.APIURL)
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithReviewerLLM(llmClient))
|
||||
}
|
||||
}
|
||||
|
||||
// Important: DisableSinkState must be before WithForceReasoning()
|
||||
if a.options.disableSinkState {
|
||||
cogitoOpts = append(cogitoOpts, cogito.DisableSinkState)
|
||||
}
|
||||
|
||||
if a.options.forceReasoning {
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoning())
|
||||
}
|
||||
|
||||
if a.options.enableGuidedTools {
|
||||
cogitoOpts = append(cogitoOpts, cogito.EnableGuidedTools)
|
||||
}
|
||||
|
||||
if a.options.maxEvaluationLoops > 0 {
|
||||
cogitoOpts = append(cogitoOpts,
|
||||
cogito.WithMaxAttempts(a.options.maxEvaluationLoops),
|
||||
cogito.WithIterations(a.options.maxEvaluationLoops),
|
||||
)
|
||||
}
|
||||
|
||||
if a.options.loopDetection > 0 {
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithLoopDetection(a.options.loopDetection))
|
||||
}
|
||||
|
||||
if a.options.forceReasoningTool {
|
||||
cogitoOpts = append(cogitoOpts,
|
||||
cogito.WithForceReasoningTool())
|
||||
}
|
||||
|
||||
if a.options.enableAutoCompaction {
|
||||
cogitoOpts = append(cogitoOpts,
|
||||
cogito.WithCompactionThreshold(a.options.autoCompactionThreshold))
|
||||
}
|
||||
|
||||
if a.options.maxAttempts > 1 {
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithMaxAttempts(a.options.maxAttempts))
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithMaxRetries(a.options.maxAttempts))
|
||||
}
|
||||
|
||||
if a.options.streamCallback != nil {
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithStreamCallback(a.options.streamCallback))
|
||||
}
|
||||
|
||||
fragment, err = cogito.ExecuteTools(
|
||||
a.llm, fragment,
|
||||
cogitoOpts...,
|
||||
@@ -1382,11 +1050,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
if finishedByCallback {
|
||||
job.Result.Finish(finishErr)
|
||||
return
|
||||
}
|
||||
|
||||
if userTool {
|
||||
return
|
||||
}
|
||||
@@ -1406,7 +1069,13 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
result := a.cleanupLLMResponse(fragment.LastMessage().Content)
|
||||
responseFragment, err := a.llm.Ask(job.GetContext(), fragment)
|
||||
if err != nil {
|
||||
job.Result.Finish(err)
|
||||
return
|
||||
}
|
||||
|
||||
result := a.cleanupLLMResponse(responseFragment.LastMessage().Content)
|
||||
|
||||
conv = append(fragment.Messages, openai.ChatCompletionMessage{
|
||||
Role: "assistant",
|
||||
@@ -1448,6 +1117,84 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
|
||||
|
||||
xlog.Debug("Agent is running periodically", "agent", a.Character.Name)
|
||||
|
||||
// Check for reminders that need to be triggered
|
||||
now := time.Now()
|
||||
var triggeredReminders []types.ReminderActionResponse
|
||||
var remainingReminders []types.ReminderActionResponse
|
||||
|
||||
for _, reminder := range a.sharedState.Reminders {
|
||||
xlog.Debug("Checking reminder", "reminder", reminder)
|
||||
if now.After(reminder.NextRun) {
|
||||
triggeredReminders = append(triggeredReminders, reminder)
|
||||
xlog.Debug("Reminder triggered", "reminder", reminder)
|
||||
// Calculate next run time for recurring reminders
|
||||
if reminder.IsRecurring {
|
||||
xlog.Debug("Reminder is recurring", "reminder", reminder)
|
||||
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(reminder.CronExpr)
|
||||
if err == nil {
|
||||
nextRun := schedule.Next(now)
|
||||
xlog.Debug("Next run time", "reminder", reminder, "nextRun", nextRun)
|
||||
reminder.LastRun = now
|
||||
reminder.NextRun = nextRun
|
||||
remainingReminders = append(remainingReminders, reminder)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
xlog.Debug("Reminder not triggered", "reminder", reminder)
|
||||
remainingReminders = append(remainingReminders, reminder)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the reminders list
|
||||
a.sharedState.Reminders = remainingReminders
|
||||
|
||||
// Handle triggered reminders
|
||||
for _, reminder := range triggeredReminders {
|
||||
xlog.Info("Processing triggered reminder", "agent", a.Character.Name, "message", reminder.Message)
|
||||
|
||||
// Create a more natural conversation flow for the reminder
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(fmt.Sprintf("I have a reminder for you: %s", reminder.Message)),
|
||||
types.WithReasoningCallback(a.options.reasoningCallback),
|
||||
types.WithResultCallback(a.options.resultCallback),
|
||||
)
|
||||
|
||||
// Add the reminder message to the job's metadata
|
||||
reminderJob.Metadata = map[string]interface{}{
|
||||
"message": reminder.Message,
|
||||
"is_reminder": true,
|
||||
}
|
||||
|
||||
// Process the reminder as a normal conversation
|
||||
a.consumeJob(reminderJob, UserRole)
|
||||
|
||||
// After the reminder job is complete, ensure the user is notified
|
||||
if reminderJob.Result != nil && reminderJob.Result.Conversation != nil {
|
||||
// Get the last assistant message from the conversation
|
||||
var lastAssistantMsg *openai.ChatCompletionMessage
|
||||
for i := len(reminderJob.Result.Conversation) - 1; i >= 0; i-- {
|
||||
if reminderJob.Result.Conversation[i].Role == AssistantRole {
|
||||
lastAssistantMsg = &reminderJob.Result.Conversation[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if lastAssistantMsg != nil && lastAssistantMsg.Content != "" {
|
||||
// Send the reminder response to the user
|
||||
msg := openai.ChatCompletionMessage{
|
||||
Role: "assistant",
|
||||
Content: fmt.Sprintf("Reminder Update: %s\n\n%s", reminder.Message, lastAssistantMsg.Content),
|
||||
}
|
||||
|
||||
go func(agent *Agent) {
|
||||
xlog.Info("Sending reminder response to user", "agent", agent.Character.Name, "message", msg.Content)
|
||||
agent.newConversations <- msg
|
||||
}(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !a.options.standaloneJob {
|
||||
return
|
||||
}
|
||||
@@ -1458,35 +1205,17 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
|
||||
// - evaluating the result
|
||||
// - asking the agent to do something else based on the result
|
||||
|
||||
innerMonologue := a.options.innerMonologueTemplate
|
||||
if innerMonologue == "" {
|
||||
innerMonologue = innerMonologueTemplate
|
||||
}
|
||||
whatNext := types.NewJob(
|
||||
types.WithText(innerMonologue),
|
||||
types.WithText(innerMonologueTemplate),
|
||||
types.WithReasoningCallback(a.options.reasoningCallback),
|
||||
types.WithResultCallback(a.options.resultCallback),
|
||||
)
|
||||
|
||||
// Attach observable so UI can show standalone job progress (decisions, actions, reasoning)
|
||||
if a.observer != nil {
|
||||
obs := a.observer.NewObservable()
|
||||
obs.Name = "standalone"
|
||||
obs.Icon = "clock"
|
||||
a.observer.Update(*obs)
|
||||
whatNext.Obs = obs
|
||||
}
|
||||
|
||||
a.consumeJob(whatNext, SystemRole)
|
||||
|
||||
xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)
|
||||
}
|
||||
|
||||
func (a *Agent) Run() error {
|
||||
// Start the scheduler
|
||||
a.taskScheduler.Start()
|
||||
xlog.Info("Task scheduler started")
|
||||
|
||||
a.startNewConversationsConsumer()
|
||||
xlog.Debug("Agent is now running", "agent", a.Character.Name)
|
||||
// The agent run does two things:
|
||||
|
||||
@@ -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"
|
||||
@@ -296,9 +296,9 @@ var _ = Describe("Agent test", func() {
|
||||
WithModel(testModel),
|
||||
WithLLMAPIKey(apiKeyURL),
|
||||
WithTimeout("10m"),
|
||||
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
|
||||
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
|
||||
mu.Lock()
|
||||
message = m.Message
|
||||
message = m
|
||||
mu.Unlock()
|
||||
}),
|
||||
WithActions(
|
||||
|
||||
+7
-163
@@ -1,34 +1,23 @@
|
||||
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 {
|
||||
// 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 {
|
||||
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
|
||||
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 {
|
||||
@@ -148,157 +137,12 @@ func (a *Agent) saveCurrentConversation(conv Messages) {
|
||||
xlog.Error("Error storing into memory", "error", err)
|
||||
}
|
||||
} else {
|
||||
// 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)
|
||||
}
|
||||
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"},
|
||||
}
|
||||
}
|
||||
|
||||
+13
-36
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
@@ -25,7 +25,6 @@ type MCPServer struct {
|
||||
}
|
||||
|
||||
type MCPSTDIOServer struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Args []string `json:"args"`
|
||||
Env []string `json:"env"`
|
||||
Cmd string `json:"cmd"`
|
||||
@@ -137,7 +136,7 @@ func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.Round
|
||||
}
|
||||
|
||||
func (a *Agent) initMCPActions() error {
|
||||
a.closeMCPServers() // Make sure we stop all previous servers if any is active
|
||||
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
|
||||
|
||||
a.mcpActionDefinitions = nil
|
||||
var err error
|
||||
@@ -155,17 +154,13 @@ func (a *Agent) initMCPActions() error {
|
||||
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
|
||||
}
|
||||
|
||||
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())
|
||||
transport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
|
||||
|
||||
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
|
||||
}
|
||||
// Create a new client
|
||||
session, err := client.Connect(a.context, transport, nil)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to connect to MCP server", "server", mcpServer, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
|
||||
@@ -178,6 +173,9 @@ 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", "script", a.options.mcpPrepareScript)
|
||||
|
||||
@@ -211,34 +209,13 @@ func (a *Agent) initMCPActions() error {
|
||||
generatedActions = append(generatedActions, actions...)
|
||||
}
|
||||
|
||||
// Pre-connected MCP sessions (e.g. in-process skills server); already in a.mcpSessions after closeMCPServers()
|
||||
for _, session := range a.options.extraMCPSessions {
|
||||
actions, err := a.addTools(session)
|
||||
if err != nil {
|
||||
xlog.Error("Failed to add tools for extra MCP session", "error", err.Error())
|
||||
continue
|
||||
}
|
||||
a.mcpSessions = append(a.mcpSessions, session)
|
||||
generatedActions = append(generatedActions, actions...)
|
||||
}
|
||||
|
||||
a.mcpActionDefinitions = generatedActions
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) closeMCPServers() {
|
||||
extraSet := make(map[*mcp.ClientSession]bool)
|
||||
for _, e := range a.options.extraMCPSessions {
|
||||
extraSet[e] = true
|
||||
}
|
||||
var keep []*mcp.ClientSession
|
||||
func (a *Agent) closeMCPSTDIOServers() {
|
||||
for _, s := range a.mcpSessions {
|
||||
if extraSet[s] {
|
||||
keep = append(keep, s)
|
||||
} else {
|
||||
s.Close()
|
||||
}
|
||||
s.Close()
|
||||
}
|
||||
a.mcpSessions = keep
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("mergeLeadingSystemMessages", func() {
|
||||
It("merges multiple leading system messages into one", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "You are a helper."},
|
||||
{Role: SystemRole, Content: "Given the user input you have the following in memory:\n- fact1"},
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("You are a helper.\n\nGiven the user input you have the following in memory:\n- fact1"))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
Expect(out[1].Content).To(Equal("hello"))
|
||||
})
|
||||
|
||||
It("prepends prefix blocks in order (self-eval then HUD)", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Main system prompt."},
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval block.", "HUD block.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Self-eval block.\n\nHUD block.\n\nMain system prompt."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
})
|
||||
|
||||
It("skips empty prefix blocks", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Only this."},
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("", "HUD.", "")
|
||||
Expect(out[0].Content).To(Equal("HUD.\n\nOnly this."))
|
||||
})
|
||||
|
||||
It("leaves mid-conversation system messages unchanged", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "Leading system."},
|
||||
{Role: "user", Content: "message with images"},
|
||||
{Role: SystemRole, Content: "Image explainer (would be rectified elsewhere)."},
|
||||
{Role: "assistant", Content: "reply"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(HaveLen(4))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Leading system."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
Expect(out[2].Role).To(Equal(SystemRole))
|
||||
Expect(out[2].Content).To(Equal("Image explainer (would be rectified elsewhere)."))
|
||||
Expect(out[3].Role).To(Equal("assistant"))
|
||||
})
|
||||
|
||||
It("returns conv unchanged when there are no leading system messages and no prefix blocks", func() {
|
||||
conv := Messages{
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages()
|
||||
Expect(out).To(Equal(conv))
|
||||
})
|
||||
|
||||
It("returns only prefix blocks as single system message when conv has no leading system messages", func() {
|
||||
conv := Messages{
|
||||
{Role: "user", Content: "hi"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(Equal("Self-eval.\n\nHUD."))
|
||||
Expect(out[1].Role).To(Equal("user"))
|
||||
})
|
||||
|
||||
It("produces exactly one leading system message with config + RAG + HUD content", func() {
|
||||
conv := Messages{
|
||||
{Role: SystemRole, Content: "RAG: memory context"},
|
||||
{Role: SystemRole, Content: "Config system prompt."},
|
||||
{Role: "user", Content: "hello"},
|
||||
}
|
||||
out := conv.mergeLeadingSystemMessages("Self-eval.", "HUD.")
|
||||
Expect(out).To(HaveLen(2))
|
||||
Expect(out[0].Role).To(Equal(SystemRole))
|
||||
Expect(out[0].Content).To(ContainSubstring("Self-eval."))
|
||||
Expect(out[0].Content).To(ContainSubstring("HUD."))
|
||||
Expect(out[0].Content).To(ContainSubstring("RAG: memory context"))
|
||||
Expect(out[0].Content).To(ContainSubstring("Config system prompt."))
|
||||
systemCount := 0
|
||||
for _, m := range out {
|
||||
if m.Role == SystemRole {
|
||||
systemCount++
|
||||
}
|
||||
}
|
||||
Expect(systemCount).To(Equal(1))
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
type Observer interface {
|
||||
@@ -17,11 +17,6 @@ type Observer interface {
|
||||
ClearHistory()
|
||||
}
|
||||
|
||||
// historyRingSize is the number of observables kept in the ring buffer. When full,
|
||||
// the oldest entry is overwritten. The UI builds a tree from parent_id; if a parent
|
||||
// is evicted before its children, those children will appear as roots or be omitted.
|
||||
const historyRingSize = 500
|
||||
|
||||
type SSEObserver struct {
|
||||
agent string
|
||||
maxID int32
|
||||
@@ -37,7 +32,7 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
|
||||
agent: agent,
|
||||
maxID: 1,
|
||||
manager: manager,
|
||||
history: make([]types.Observable, historyRingSize),
|
||||
history: make([]types.Observable, 100),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +92,6 @@ func (s *SSEObserver) ClearHistory() {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.history = make([]types.Observable, historyRingSize)
|
||||
s.history = make([]types.Observable, 100)
|
||||
s.historyLast = 0
|
||||
}
|
||||
|
||||
+10
-190
@@ -5,31 +5,17 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/cogito"
|
||||
"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
|
||||
ReviewerModel string
|
||||
TranscriptionModel string
|
||||
TranscriptionLanguage string
|
||||
TTSModel string
|
||||
@@ -44,38 +30,27 @@ type options struct {
|
||||
jobFilters types.JobFilters
|
||||
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
|
||||
stripThinkingTags bool
|
||||
kbAutoSearch bool
|
||||
conversationStorageMode ConversationStorageMode
|
||||
|
||||
canStopItself bool
|
||||
initiateConversations bool
|
||||
forceReasoning bool
|
||||
forceReasoningTool 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
|
||||
|
||||
// Evaluation settings
|
||||
maxEvaluationLoops int
|
||||
loopDetection int
|
||||
enableEvaluation bool
|
||||
|
||||
prompts []DynamicPrompt
|
||||
|
||||
systemPrompt string
|
||||
innerMonologueTemplate string
|
||||
skillPromptTemplate string
|
||||
schedulerTaskTemplate string
|
||||
systemPrompt string
|
||||
|
||||
// callbacks
|
||||
reasoningCallback func(types.ActionCurrentState) bool
|
||||
@@ -86,24 +61,12 @@ type options struct {
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpPrepareScript string
|
||||
extraMCPSessions []*mcp.ClientSession
|
||||
newConversationsSubscribers []func(*types.ConversationMessage)
|
||||
newConversationsSubscribers []func(openai.ChatCompletionMessage)
|
||||
|
||||
observer Observer
|
||||
enableAutoCompaction bool
|
||||
autoCompactionThreshold int
|
||||
observer Observer
|
||||
parallelJobs int
|
||||
|
||||
lastMessageDuration time.Duration
|
||||
|
||||
// cancelPreviousOnNewMessage: when true (or nil), Enqueue cancels the running job for the same conversation_id. When false, jobs are queued.
|
||||
cancelPreviousOnNewMessage *bool
|
||||
|
||||
// maxAttempts: on ExecuteTools failure, retry up to this many times before surfacing the error to the user (1 = no retries).
|
||||
maxAttempts int
|
||||
|
||||
// streamCallback receives streaming events from cogito during final answer generation.
|
||||
streamCallback func(cogito.StreamEvent)
|
||||
}
|
||||
|
||||
func (o *options) SeparatedMultimodalModel() bool {
|
||||
@@ -112,14 +75,10 @@ func (o *options) SeparatedMultimodalModel() bool {
|
||||
|
||||
func defaultOptions() *options {
|
||||
return &options{
|
||||
parallelJobs: 1,
|
||||
maxAttempts: 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
|
||||
parallelJobs: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
maxEvaluationLoops: 2,
|
||||
enableEvaluation: false,
|
||||
LLMAPI: llmOptions{
|
||||
APIURL: "http://localhost:8080",
|
||||
Model: "gpt-4",
|
||||
@@ -157,16 +116,6 @@ var EnableForceReasoning = func(o *options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableGuidedTools = func(o *options) error {
|
||||
o.enableGuidedTools = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableForceReasoningTool = func(o *options) error {
|
||||
o.forceReasoningTool = true
|
||||
return nil
|
||||
}
|
||||
|
||||
var EnableKnowledgeBase = func(o *options) error {
|
||||
o.enableKB = true
|
||||
o.kbResults = 5
|
||||
@@ -218,30 +167,7 @@ func WithParallelJobs(jobs int) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithCancelPreviousOnNewMessage sets whether a new job with the same conversation_id cancels the currently running job (true) or is queued (false). Nil/default means true.
|
||||
func WithCancelPreviousOnNewMessage(cancel bool) Option {
|
||||
return func(o *options) error {
|
||||
o.cancelPreviousOnNewMessage = &cancel
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxAttempts sets how many times to attempt execution on failure before surfacing the error to the user (1 = no retries).
|
||||
func WithMaxAttempts(attempts int) Option {
|
||||
return func(o *options) error {
|
||||
o.maxAttempts = attempts
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithLoopDetection(loops int) Option {
|
||||
return func(o *options) error {
|
||||
o.loopDetection = loops
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
|
||||
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
|
||||
return func(o *options) error {
|
||||
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
|
||||
return nil
|
||||
@@ -258,18 +184,6 @@ 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 {
|
||||
@@ -299,19 +213,6 @@ 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
|
||||
@@ -319,22 +220,6 @@ func WithSystemPrompt(prompt string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithInnerMonologueTemplate sets the prompt used for periodic/standalone runs. If empty, the default template is used.
|
||||
func WithInnerMonologueTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.innerMonologueTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSkillPromptTemplate sets the template for rendering skills in the prompt. If empty, the default template is used.
|
||||
func WithSkillPromptTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.skillPromptTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithMCPServers(servers ...MCPServer) Option {
|
||||
return func(o *options) error {
|
||||
o.mcpServers = servers
|
||||
@@ -387,14 +272,6 @@ func WithPrompts(prompts ...DynamicPrompt) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithMCPSession adds a pre-connected MCP client session (e.g. in-process skills MCP) to the agent.
|
||||
func WithMCPSession(session *mcp.ClientSession) Option {
|
||||
return func(o *options) error {
|
||||
o.extraMCPSessions = append(o.extraMCPSessions, session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithDynamicPrompts is a helper function to create dynamic prompts
|
||||
// Dynamic prompts contains golang code which is executed dynamically
|
||||
// // to render a prompt to the LLM
|
||||
@@ -443,18 +320,6 @@ 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
|
||||
@@ -512,7 +377,7 @@ func WithRandomIdentity(guidance ...string) Option {
|
||||
|
||||
func WithActions(actions ...types.Action) Option {
|
||||
return func(o *options) error {
|
||||
o.userActions = append(o.userActions, actions...)
|
||||
o.userActions = actions
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -570,48 +435,3 @@ func WithTTSModel(model string) Option {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// WithSchedulerTaskTemplate sets the prompt used for scheduled/recurring tasks run by the scheduler.
|
||||
// If empty, the default inner monologue template is used with the task injected.
|
||||
func WithSchedulerTaskTemplate(template string) Option {
|
||||
return func(o *options) error {
|
||||
o.schedulerTaskTemplate = template
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var EnableAutoCompaction = func(o *options) error {
|
||||
o.enableAutoCompaction = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func WithAutoCompactionThreshold(threshold int) Option {
|
||||
return func(o *options) error {
|
||||
o.autoCompactionThreshold = threshold
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithStreamCallback sets a callback to receive streaming events from cogito
|
||||
// during final answer generation. This enables live token-by-token delivery.
|
||||
func WithStreamCallback(fn func(cogito.StreamEvent)) Option {
|
||||
return func(o *options) error {
|
||||
o.streamCallback = fn
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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) {
|
||||
// Render the scheduler task template - if custom template is set, it will include {{.Task}}
|
||||
// If no custom scheduler template is set, fall back to default inner monologue template
|
||||
innerMonologue := 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)
|
||||
|
||||
if e.agent.options.schedulerTaskTemplate != "" {
|
||||
tmpl, err := templateBase("taskTemplate", e.agent.options.schedulerTaskTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
|
||||
innerMonologue, err = templateExecute(tmpl, &InnerMonologueTemplateData{
|
||||
CommonTemplateData: CommonTemplateData{
|
||||
AgentName: agentName,
|
||||
},
|
||||
Task: prompt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a job for the reminder with the rendered inner monologue
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(innerMonologue),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,6 @@ import (
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
type CommonTemplateData struct {
|
||||
AgentName string
|
||||
}
|
||||
|
||||
type InnerMonologueTemplateData struct {
|
||||
CommonTemplateData
|
||||
Task string
|
||||
}
|
||||
|
||||
func templateBase(templateName, templatetext string) (*template.Template, error) {
|
||||
return template.New(templateName).Funcs(sprig.FuncMap()).Parse(templatetext)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,156 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ type (
|
||||
Manager interface {
|
||||
Send(message Envelope)
|
||||
Handle(ctx *fiber.Ctx, cl Listener)
|
||||
Register(cl Listener)
|
||||
Unregister(id string)
|
||||
Clients() []string
|
||||
}
|
||||
|
||||
@@ -112,17 +110,6 @@ func (manager *broadcastManager) Send(message Envelope) {
|
||||
manager.broadcast <- message
|
||||
}
|
||||
|
||||
// Register adds a client to the broadcast list and sends message history.
|
||||
func (manager *broadcastManager) Register(cl Listener) {
|
||||
manager.register(cl)
|
||||
manager.messageHistory.Send(cl)
|
||||
}
|
||||
|
||||
// Unregister removes a client from the broadcast list and closes its channel.
|
||||
func (manager *broadcastManager) Unregister(id string) {
|
||||
manager.unregister(id)
|
||||
}
|
||||
|
||||
// Handle sets up a new client and handles the connection.
|
||||
func (manager *broadcastManager) Handle(c *fiber.Ctx, cl Listener) {
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// KBCompactionClient is the interface used by compaction. It can be implemented by the HTTP RAG client adapter or by the in-process collection adapter.
|
||||
type KBCompactionClient interface {
|
||||
Collection() string
|
||||
ListEntries() ([]string, error)
|
||||
GetEntryContent(entry string) (content string, chunkCount int, err error)
|
||||
Store(filePath string) error
|
||||
DeleteEntry(entry string) error
|
||||
}
|
||||
|
||||
// wrappedClientCompactionAdapter adapts *localrag.WrappedClient to KBCompactionClient.
|
||||
type wrappedClientCompactionAdapter struct {
|
||||
*localrag.WrappedClient
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) ListEntries() ([]string, error) {
|
||||
return a.Client.ListEntries(a.Collection())
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) Store(filePath string) error {
|
||||
_, err := a.Client.Store(a.Collection(), filePath)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *wrappedClientCompactionAdapter) DeleteEntry(entry string) error {
|
||||
_, err := a.Client.DeleteEntry(a.Collection(), entry)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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 KBCompactionClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
collection := client.Collection()
|
||||
entries, err := client.ListEntries()
|
||||
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.Store(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.DeleteEntry(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 KBCompactionClient, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-251
@@ -3,7 +3,6 @@ package state
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
@@ -11,21 +10,6 @@ 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"`
|
||||
@@ -74,46 +58,26 @@ type AgentConfig struct {
|
||||
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"`
|
||||
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
|
||||
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"`
|
||||
SchedulerTaskTemplate string `json:"scheduler_task_template" form:"scheduler_task_template"`
|
||||
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"`
|
||||
EnableForceReasoningTool bool `json:"enable_reasoning_tool" form:"enable_reasoning_tool"`
|
||||
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
|
||||
EnableSkills bool `json:"enable_skills" form:"enable_skills"`
|
||||
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
|
||||
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
|
||||
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
|
||||
SkillsPrompt string `json:"skills_prompt" form:"skills_prompt"`
|
||||
InnerMonologueTemplate string `json:"inner_monologue_template" form:"inner_monologue_template"`
|
||||
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"`
|
||||
CancelPreviousOnNewMessage *bool `json:"cancel_previous_on_new_message" form:"cancel_previous_on_new_message"`
|
||||
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"`
|
||||
MaxAttempts int `json:"max_attempts" form:"max_attempts"`
|
||||
LoopDetection int `json:"loop_detection" form:"loop_detection"`
|
||||
EnableAutoCompaction bool `json:"enable_auto_compaction" form:"enable_auto_compaction"`
|
||||
AutoCompactionThreshold int `json:"auto_compaction_threshold" form:"auto_compaction_threshold"`
|
||||
Name string `json:"name" form:"name"`
|
||||
HUD bool `json:"hud" form:"hud"`
|
||||
StandaloneJob bool `json:"standalone_job" form:"standalone_job"`
|
||||
RandomIdentity bool `json:"random_identity" form:"random_identity"`
|
||||
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
|
||||
CanPlan bool `json:"enable_planning" form:"enable_planning"`
|
||||
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
|
||||
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
|
||||
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
|
||||
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
|
||||
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
|
||||
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AgentConfigMeta struct {
|
||||
@@ -204,13 +168,6 @@ func NewAgentConfigMeta(
|
||||
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",
|
||||
@@ -255,31 +212,6 @@ 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",
|
||||
@@ -294,35 +226,6 @@ 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",
|
||||
@@ -339,30 +242,6 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Long-term objective for the agent to pursue",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "skills_prompt",
|
||||
Label: "Skills Prompt",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Optional instructions for using skills. Used when Enable Skills is on. If empty, default instructions are used.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "inner_monologue_template",
|
||||
Label: "Inner Monologue Template",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Prompt used for periodic/standalone runs when the agent evaluates what to do next. If empty, the default autonomous agent instructions are used.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "scheduler_task_template",
|
||||
Label: "Scheduler Task Template",
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Template for scheduled/recurring tasks. Use {{.Task}} to reference the task. Example: \"Execute: {{.Task}}\". If empty, the default inner monologue template is used with the task injected.",
|
||||
Tags: config.Tags{Section: "PromptsGoals"},
|
||||
},
|
||||
{
|
||||
Name: "standalone_job",
|
||||
Label: "Standalone Job",
|
||||
@@ -387,24 +266,6 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Enable agent to create and execute plans",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "cancel_previous_on_new_message",
|
||||
Label: "Cancel previous message on new message",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "When a new message arrives for the same conversation, cancel the currently running job and start the new one. If disabled, new messages are queued.",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "loop_detection",
|
||||
Label: "Loop Detection",
|
||||
Type: "number",
|
||||
DefaultValue: 5,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of messages to check for loop detection. If a message is the same as the previous message, the job is cancelled.",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "can_stop_itself",
|
||||
Label: "Can Stop Itself",
|
||||
@@ -422,45 +283,12 @@ 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",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Enable agent to explain its reasoning process",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_reasoning_tool",
|
||||
Label: "Enable Reasoning for tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "Enable agent to reason more on tools",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
{
|
||||
Name: "enable_skills",
|
||||
Label: "Enable Skills",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Inject available skills into the agent and expose skill tools (list, read, search, resources) via MCP",
|
||||
HelpText: "Enable agent to explain its reasoning process",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
@@ -473,21 +301,13 @@ 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: "MCP"},
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "mcp_prepare_script",
|
||||
@@ -495,7 +315,7 @@ func NewAgentConfigMeta(
|
||||
Type: "textarea",
|
||||
DefaultValue: "",
|
||||
HelpText: "Script to prepare for running MCP servers",
|
||||
Tags: config.Tags{Section: "MCP"},
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "strip_thinking_tags",
|
||||
@@ -505,24 +325,6 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Remove content between <thinking></thinking> and <think></think> tags from agent responses",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_auto_compaction",
|
||||
Label: "Enable Auto Compaction",
|
||||
Type: "checkbox",
|
||||
DefaultValue: false,
|
||||
HelpText: "Enable automatic conversation compaction when token threshold is reached",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "auto_compaction_threshold",
|
||||
Label: "Auto Compaction Threshold (tokens)",
|
||||
Type: "number",
|
||||
DefaultValue: 4096,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of tokens to trigger automatic compaction",
|
||||
Tags: config.Tags{Section: "ModelSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_evaluation",
|
||||
Label: "Enable Evaluation",
|
||||
@@ -541,16 +343,6 @@ func NewAgentConfigMeta(
|
||||
HelpText: "Maximum number of evaluation loops to perform when addressing gaps in responses",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "max_attempts",
|
||||
Label: "Max Attempts",
|
||||
Type: "number",
|
||||
DefaultValue: 1,
|
||||
Min: 1,
|
||||
Step: 1,
|
||||
HelpText: "Number of attempts on failure before surfacing the error to the user (1 = no retries)",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "last_message_duration",
|
||||
Label: "Last Message Duration",
|
||||
@@ -594,10 +386,6 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
aux := &struct {
|
||||
*Alias
|
||||
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
|
||||
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
|
||||
MaxAttempts interface{} `json:"max_attempts"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
KnowledgeBaseResults interface{} `json:"kb_results"`
|
||||
}{
|
||||
Alias: (*Alias)(a),
|
||||
}
|
||||
@@ -606,13 +394,6 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse integer fields that may come as strings
|
||||
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
|
||||
a.MaxAttempts = parseIntField(aux.MaxAttempts)
|
||||
a.ParallelJobs = parseIntField(aux.ParallelJobs)
|
||||
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
|
||||
a.LoopDetection = parseIntField(aux.LoopDetection)
|
||||
|
||||
// Handle MCP STDIO servers configuration
|
||||
if aux.MCPSTDIOServersConfig != nil {
|
||||
switch v := aux.MCPSTDIOServersConfig.(type) {
|
||||
@@ -631,7 +412,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(mcpConfig.MCPServers))
|
||||
for name, server := range mcpConfig.MCPServers {
|
||||
for _, 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 {
|
||||
@@ -639,7 +420,6 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: server.Command,
|
||||
Args: server.Args,
|
||||
Env: envSlice,
|
||||
@@ -654,7 +434,6 @@ 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 {
|
||||
@@ -675,7 +454,6 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
|
||||
Name: name,
|
||||
Cmd: cmd,
|
||||
Args: args,
|
||||
Env: env,
|
||||
@@ -724,11 +502,7 @@ func (a *AgentConfig) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
key := server.Name
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("server%d", i)
|
||||
}
|
||||
mcpConfig.MCPServers[key] = struct {
|
||||
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = struct {
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
Env map[string]string `json:"env"`
|
||||
|
||||
+152
-358
@@ -2,6 +2,7 @@ package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -12,68 +13,34 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
sseLib "github.com/mudler/LocalAGI/core/sse"
|
||||
"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/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/cogito"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
// SkillsProvider supplies the skills dynamic prompt and MCP session when skills are enabled for an agent.
|
||||
type SkillsProvider interface {
|
||||
GetSkillsPrompt(config *AgentConfig) (DynamicPrompt, error)
|
||||
GetMCPSession(ctx context.Context) (*mcp.ClientSession, error)
|
||||
}
|
||||
|
||||
// RAGProvider returns a RAGDB and optional compaction client for a collection (e.g. agent name).
|
||||
// effectiveRAGURL/Key are pool/agent defaults; implementation may use them (HTTP) or ignore them (embedded).
|
||||
type RAGProvider func(collectionName, effectiveRAGURL, effectiveRAGKey string) (RAGDB, KBCompactionClient, bool)
|
||||
|
||||
// NewHTTPRAGProvider returns a RAGProvider that uses the LocalRAG HTTP API. When effective URL/key are empty, baseURL/baseKey are used.
|
||||
func NewHTTPRAGProvider(baseURL, baseKey string) RAGProvider {
|
||||
return func(collectionName, effectiveURL, effectiveKey string) (RAGDB, KBCompactionClient, bool) {
|
||||
url := effectiveURL
|
||||
if url == "" {
|
||||
url = baseURL
|
||||
}
|
||||
key := effectiveKey
|
||||
if key == "" {
|
||||
key = baseKey
|
||||
}
|
||||
wc := localrag.NewWrappedClient(url, key, collectionName)
|
||||
return wc, &wrappedClientCompactionAdapter{WrappedClient: wc}, true
|
||||
}
|
||||
}
|
||||
|
||||
type AgentPool struct {
|
||||
sync.Mutex
|
||||
file string
|
||||
pooldir string
|
||||
pool AgentPoolData
|
||||
agents map[string]*Agent
|
||||
managers map[string]sseLib.Manager
|
||||
managers map[string]sse.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
|
||||
defaultTranscriptionModel, defaultTranscriptionLanguage string
|
||||
apiKey string
|
||||
ragProvider RAGProvider
|
||||
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
|
||||
skillsService SkillsProvider
|
||||
}
|
||||
|
||||
// SetRAGProvider sets the single RAG provider (HTTP or embedded). Must be called after pool creation.
|
||||
func (a *AgentPool) SetRAGProvider(fn RAGProvider) {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
a.ragProvider = fn
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -107,14 +74,14 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
|
||||
}
|
||||
|
||||
func NewAgentPool(
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
|
||||
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, imageModel, apiURL, apiKey, directory string,
|
||||
LocalRAGAPI string,
|
||||
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
|
||||
connectors func(*AgentConfig) []Connector,
|
||||
promptBlocks func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt,
|
||||
filters func(*AgentConfig) types.JobFilters,
|
||||
timeout string,
|
||||
withLogs bool,
|
||||
skillsService SkillsProvider,
|
||||
) (*AgentPool, error) {
|
||||
// if file exists, try to load an existing pool.
|
||||
// if file does not exist, create a new pool.
|
||||
@@ -125,6 +92,7 @@ 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{
|
||||
@@ -136,34 +104,25 @@ func NewAgentPool(
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
imageModel: imageModel,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
pool: make(map[string]AgentConfig),
|
||||
agentStatus: make(map[string]*Status),
|
||||
managers: make(map[string]sseLib.Manager),
|
||||
managers: make(map[string]sse.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
poolData, err := loadPoolFromFile(poolfile)
|
||||
if err != nil {
|
||||
bakPath := poolfile + ".bak"
|
||||
poolData, err = loadPoolFromFile(bakPath)
|
||||
if err != nil {
|
||||
xlog.Warn("Pool file invalid and backup missing or invalid, starting with empty pool", "poolfile", poolfile, "error", err)
|
||||
poolData = &AgentPoolData{}
|
||||
} else {
|
||||
xlog.Info("Recovered pool from backup, repairing main file", "poolfile", poolfile)
|
||||
if repairData, _ := json.MarshalIndent(poolData, "", " "); len(repairData) > 0 {
|
||||
_ = os.WriteFile(poolfile, repairData, 0644)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &AgentPool{
|
||||
file: poolfile,
|
||||
@@ -174,18 +133,19 @@ func NewAgentPool(
|
||||
defaultTranscriptionModel: defaultTranscriptionModel,
|
||||
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
imageModel: imageModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sseLib.Manager),
|
||||
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,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -194,14 +154,6 @@ func replaceInvalidChars(s string) string {
|
||||
return strings.ReplaceAll(s, " ", "_")
|
||||
}
|
||||
|
||||
// StartAgentStandalone starts an agent without saving it to the pool registry.
|
||||
// It is intended for running a single agent from the CLI without the web server.
|
||||
func (a *AgentPool) StartAgentStandalone(name string, agentConfig *AgentConfig) error {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
|
||||
}
|
||||
|
||||
// CreateAgent adds a new agent to the pool
|
||||
// and starts it.
|
||||
// It also saves the state to the file.
|
||||
@@ -218,7 +170,14 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
|
||||
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)
|
||||
}
|
||||
|
||||
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
@@ -227,22 +186,22 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
|
||||
oldAgent := a.agents[name]
|
||||
var o *types.Observable
|
||||
var obs Observer
|
||||
if oldAgent != nil {
|
||||
obs = oldAgent.Observer()
|
||||
if obs != nil {
|
||||
o = obs.NewObservable()
|
||||
o.Name = "Restarting Agent"
|
||||
o.Icon = "sync"
|
||||
o.Creation = &types.Creation{}
|
||||
obs.Update(*o)
|
||||
}
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
os.Remove(stateFile)
|
||||
os.Remove(characterFile)
|
||||
oldAgent.Stop()
|
||||
obs := oldAgent.Observer()
|
||||
if obs != nil {
|
||||
o = obs.NewObservable()
|
||||
o.Name = "Restarting Agent"
|
||||
o.Icon = "sync"
|
||||
o.Creation = &types.Creation{}
|
||||
obs.Update(*o)
|
||||
}
|
||||
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
|
||||
os.Remove(stateFile)
|
||||
os.Remove(characterFile)
|
||||
|
||||
oldAgent.Stop()
|
||||
|
||||
a.pool[name] = *agentConfig
|
||||
delete(a.agents, name)
|
||||
|
||||
@@ -254,7 +213,7 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
|
||||
if err := a.startAgentWithConfig(name, agentConfig, obs); err != nil {
|
||||
if obs != nil {
|
||||
o.Completion = &types.Completion{Error: err.Error()}
|
||||
obs.Update(*o)
|
||||
@@ -270,6 +229,84 @@ 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()
|
||||
@@ -291,12 +328,12 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
||||
return a.agentStatus[name]
|
||||
}
|
||||
|
||||
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
|
||||
var manager sseLib.Manager
|
||||
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
|
||||
var manager sse.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
} else {
|
||||
manager = sseLib.NewManager(5)
|
||||
manager = sse.NewManager(5)
|
||||
}
|
||||
ctx := context.Background()
|
||||
model := a.defaultModel
|
||||
@@ -330,33 +367,29 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
config.PeriodicRuns = "10m"
|
||||
}
|
||||
|
||||
if config.SchedulerPollInterval == "" {
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// Use agent-specific config when set, otherwise pool defaults. Do not update pool from agent config.
|
||||
effectiveAPIURL := a.apiURL
|
||||
// XXX: Why do we update the pool config from an Agent's config?
|
||||
if config.APIURL != "" {
|
||||
effectiveAPIURL = config.APIURL
|
||||
a.apiURL = config.APIURL
|
||||
} else {
|
||||
config.APIURL = a.apiURL
|
||||
}
|
||||
effectiveAPIKey := a.apiKey
|
||||
|
||||
if config.APIKey != "" {
|
||||
effectiveAPIKey = config.APIKey
|
||||
a.apiKey = config.APIKey
|
||||
} else {
|
||||
config.APIKey = a.apiKey
|
||||
}
|
||||
effectiveLocalRAGAPI := config.LocalRAGURL
|
||||
effectiveLocalRAGKey := config.LocalRAGAPIKey
|
||||
|
||||
if config.LocalRAGURL != "" {
|
||||
a.localRAGAPI = config.LocalRAGURL
|
||||
}
|
||||
|
||||
if config.LocalRAGAPIKey != "" {
|
||||
a.localRAGKey = config.LocalRAGAPIKey
|
||||
}
|
||||
|
||||
connectors := a.connectors(config)
|
||||
promptBlocks := a.dynamicPrompt(config)(ctx, a)
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
|
||||
promptBlocks = append(promptBlocks, prompt)
|
||||
}
|
||||
}
|
||||
actions := a.availableActions(config)(ctx, a)
|
||||
filters := a.filters(config)
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
@@ -380,7 +413,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
"Creating agent",
|
||||
"name", name,
|
||||
"model", model,
|
||||
"api_url", effectiveAPIURL,
|
||||
"api_url", a.apiURL,
|
||||
"actions", actionsLog,
|
||||
"connectors", connectorLog,
|
||||
"filters", filtersLog,
|
||||
@@ -396,16 +429,14 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
}
|
||||
|
||||
opts := []Option{
|
||||
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
|
||||
WithModel(model),
|
||||
WithLLMAPIURL(effectiveAPIURL),
|
||||
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...),
|
||||
WithPrompts(promptBlocks...),
|
||||
@@ -420,23 +451,20 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
),
|
||||
WithStateFile(stateFile),
|
||||
WithCharacterFile(characterFile),
|
||||
WithLLMAPIKey(effectiveAPIKey),
|
||||
WithLLMAPIKey(a.apiKey),
|
||||
WithTimeout(a.timeout),
|
||||
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
|
||||
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
|
||||
var actionName types.ActionDefinitionName
|
||||
if state.Action != nil {
|
||||
actionName = state.Action.Definition().Name
|
||||
}
|
||||
xlog.Info(
|
||||
"Agent is thinking",
|
||||
"agent", name,
|
||||
"reasoning", state.Reasoning,
|
||||
"action", actionName,
|
||||
"action", state.Action.Definition().Name,
|
||||
"params", state.Params,
|
||||
)
|
||||
|
||||
manager.Send(
|
||||
sseLib.NewMessage(
|
||||
sse.NewMessage(
|
||||
fmt.Sprintf(`Thinking: %s`, utils.HTMLify(state.Reasoning)),
|
||||
).WithEvent("status"),
|
||||
)
|
||||
@@ -449,8 +477,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
return true
|
||||
}),
|
||||
WithSystemPrompt(config.SystemPrompt),
|
||||
WithInnerMonologueTemplate(config.InnerMonologueTemplate),
|
||||
WithSchedulerTaskTemplate(config.SchedulerTaskTemplate),
|
||||
WithMultimodalModel(multimodalModel),
|
||||
WithLastMessageDuration(config.LastMessageDuration),
|
||||
WithAgentResultCallback(func(state types.ActionState) {
|
||||
@@ -465,20 +491,16 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
"Calling agent result callback",
|
||||
)
|
||||
|
||||
var actionName types.ActionDefinitionName
|
||||
if state.ActionCurrentState.Action != nil {
|
||||
actionName = state.ActionCurrentState.Action.Definition().Name
|
||||
}
|
||||
text := fmt.Sprintf(`Reasoning: %s
|
||||
Action taken: %+v
|
||||
Parameters: %+v
|
||||
Result: %s`,
|
||||
state.Reasoning,
|
||||
actionName,
|
||||
state.ActionCurrentState.Action.Definition().Name,
|
||||
state.ActionCurrentState.Params,
|
||||
state.Result)
|
||||
manager.Send(
|
||||
sseLib.NewMessage(
|
||||
sse.NewMessage(
|
||||
utils.HTMLify(
|
||||
text,
|
||||
),
|
||||
@@ -512,10 +534,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
opts = append(opts, EnableSummaryMemory)
|
||||
}
|
||||
|
||||
if config.ConversationStorageMode != "" {
|
||||
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
|
||||
}
|
||||
|
||||
if config.CanStopItself {
|
||||
opts = append(opts, CanStopItself)
|
||||
}
|
||||
@@ -524,14 +542,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
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)
|
||||
}
|
||||
@@ -544,57 +554,18 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
}
|
||||
}
|
||||
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if session, err := a.skillsService.GetMCPSession(ctx); err == nil && session != nil {
|
||||
opts = append(opts, WithMCPSession(session))
|
||||
}
|
||||
}
|
||||
|
||||
var ragDB RAGDB
|
||||
var compactionClient KBCompactionClient
|
||||
if config.EnableKnowledgeBase && a.ragProvider != nil {
|
||||
if db, comp, ok := a.ragProvider(name, effectiveLocalRAGAPI, effectiveLocalRAGKey); ok && db != nil {
|
||||
ragDB = db
|
||||
compactionClient = comp
|
||||
}
|
||||
}
|
||||
if ragDB != nil {
|
||||
opts = append(opts, WithRAGDB(ragDB), EnableKnowledgeBase)
|
||||
kbAutoSearch := config.KBAutoSearch
|
||||
if !config.KBAutoSearch && !config.KBAsTools {
|
||||
kbAutoSearch = true
|
||||
}
|
||||
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
|
||||
if config.KBAsTools {
|
||||
kbResults := config.KnowledgeBaseResults
|
||||
if kbResults <= 0 {
|
||||
kbResults = 5
|
||||
}
|
||||
searchAction, addAction := NewKBWrapperActions(ragDB, kbResults)
|
||||
opts = append(opts, WithActions(searchAction, addAction))
|
||||
}
|
||||
if config.EnableKnowledgeBase {
|
||||
opts = append(opts, EnableKnowledgeBase)
|
||||
}
|
||||
|
||||
if config.EnableReasoning {
|
||||
opts = append(opts, EnableForceReasoning)
|
||||
}
|
||||
|
||||
if config.EnableGuidedTools {
|
||||
opts = append(opts, EnableGuidedTools)
|
||||
}
|
||||
|
||||
if config.StripThinkingTags {
|
||||
opts = append(opts, EnableStripThinkingTags)
|
||||
}
|
||||
|
||||
if config.EnableAutoCompaction {
|
||||
opts = append(opts, EnableAutoCompaction)
|
||||
}
|
||||
|
||||
if config.AutoCompactionThreshold > 0 {
|
||||
opts = append(opts, WithAutoCompactionThreshold(config.AutoCompactionThreshold))
|
||||
}
|
||||
|
||||
if config.KnowledgeBaseResults > 0 {
|
||||
opts = append(opts, EnableKnowledgeBaseWithResults(config.KnowledgeBaseResults))
|
||||
}
|
||||
@@ -603,12 +574,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
opts = append(opts, WithParallelJobs(config.ParallelJobs))
|
||||
}
|
||||
|
||||
if config.CancelPreviousOnNewMessage != nil {
|
||||
opts = append(opts, WithCancelPreviousOnNewMessage(*config.CancelPreviousOnNewMessage))
|
||||
} else {
|
||||
opts = append(opts, WithCancelPreviousOnNewMessage(true))
|
||||
}
|
||||
|
||||
if config.EnableEvaluation {
|
||||
opts = append(opts, EnableEvaluation())
|
||||
}
|
||||
@@ -617,52 +582,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
opts = append(opts, WithMaxEvaluationLoops(config.MaxEvaluationLoops))
|
||||
}
|
||||
|
||||
if config.MaxAttempts > 0 {
|
||||
opts = append(opts, WithMaxAttempts(config.MaxAttempts))
|
||||
}
|
||||
|
||||
if config.LoopDetection > 0 {
|
||||
opts = append(opts, WithLoopDetection(config.LoopDetection))
|
||||
}
|
||||
|
||||
if config.EnableForceReasoningTool {
|
||||
opts = append(opts, EnableForceReasoningTool)
|
||||
}
|
||||
|
||||
// Wire cogito streaming events into the SSE manager for live token delivery
|
||||
opts = append(opts, WithStreamCallback(func(ev cogito.StreamEvent) {
|
||||
switch ev.Type {
|
||||
case cogito.StreamEventReasoning:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "reasoning",
|
||||
"content": ev.Content,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventContent:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "content",
|
||||
"content": ev.Content,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventToolCall:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "tool_call",
|
||||
"tool_name": ev.ToolName,
|
||||
"tool_args": ev.ToolArgs,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
case cogito.StreamEventDone:
|
||||
data, _ := json.Marshal(map[string]interface{}{
|
||||
"type": "done",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
manager.Send(sseLib.NewMessage(string(data)).WithEvent("stream_event"))
|
||||
}
|
||||
}))
|
||||
|
||||
xlog.Info("Starting agent", "name", name, "config", config)
|
||||
|
||||
agent, err := New(opts...)
|
||||
@@ -679,10 +598,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
}
|
||||
}()
|
||||
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil {
|
||||
go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model)
|
||||
}
|
||||
|
||||
xlog.Info("Starting connectors", "name", name, "config", config)
|
||||
|
||||
for _, c := range connectors {
|
||||
@@ -692,7 +607,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Second) // Send a message every seconds
|
||||
manager.Send(sseLib.NewMessage(
|
||||
manager.Send(sse.NewMessage(
|
||||
utils.HTMLify(agent.State().String()),
|
||||
).WithEvent("hud"))
|
||||
}
|
||||
@@ -711,7 +626,7 @@ func (a *AgentPool) StartAll() error {
|
||||
if a.agents[name] != nil { // Agent already started
|
||||
continue
|
||||
}
|
||||
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
|
||||
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
|
||||
xlog.Error("Failed to start agent", "name", name, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -749,121 +664,12 @@ func (a *AgentPool) Start(name string) error {
|
||||
return nil
|
||||
}
|
||||
if config, ok := a.pool[name]; ok {
|
||||
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
|
||||
return a.startAgentWithConfig(name, &config, nil)
|
||||
}
|
||||
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
}
|
||||
|
||||
// CreateOnly creates the agent instance without calling Run().
|
||||
// This is used in distributed mode where the agent is executed statelessly
|
||||
// via AskDirect() — the persistent Run() loop is not needed.
|
||||
func (a *AgentPool) CreateOnly(name string) error {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
if _, ok := a.agents[name]; ok {
|
||||
return nil // already created
|
||||
}
|
||||
if config, ok := a.pool[name]; ok {
|
||||
return a.createAgentWithoutRun(name, a.pooldir, &config)
|
||||
}
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
}
|
||||
|
||||
// createAgentWithoutRun is like startAgentWithConfig but skips Run(), connectors, and HUD.
|
||||
func (a *AgentPool) createAgentWithoutRun(name, pooldir string, config *AgentConfig) error {
|
||||
var manager sseLib.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
} else {
|
||||
manager = sseLib.NewManager(5)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
effectiveAPIURL := a.apiURL
|
||||
if config.APIURL != "" {
|
||||
effectiveAPIURL = config.APIURL
|
||||
} else {
|
||||
config.APIURL = a.apiURL
|
||||
}
|
||||
effectiveAPIKey := a.apiKey
|
||||
if config.APIKey != "" {
|
||||
effectiveAPIKey = config.APIKey
|
||||
} else {
|
||||
config.APIKey = a.apiKey
|
||||
}
|
||||
|
||||
promptBlocks := a.dynamicPrompt(config)(ctx, a)
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if prompt, err := a.skillsService.GetSkillsPrompt(config); err == nil && prompt != nil {
|
||||
promptBlocks = append(promptBlocks, prompt)
|
||||
}
|
||||
}
|
||||
actions := a.availableActions(config)(ctx, a)
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
|
||||
obs := NewSSEObserver(name, manager)
|
||||
|
||||
opts := []Option{
|
||||
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
|
||||
WithModel(model),
|
||||
WithLLMAPIURL(effectiveAPIURL),
|
||||
WithContext(ctx),
|
||||
WithTranscriptionModel(transcriptionModel),
|
||||
WithTranscriptionLanguage(transcriptionLanguage),
|
||||
WithTTSModel(ttsModel),
|
||||
WithPrompts(promptBlocks...),
|
||||
WithActions(actions...),
|
||||
WithObserver(obs),
|
||||
WithMultimodalModel(multimodalModel),
|
||||
WithCharacterFile(characterFile),
|
||||
WithStateFile(stateFile),
|
||||
WithSystemPrompt(config.SystemPrompt),
|
||||
}
|
||||
if effectiveAPIKey != "" {
|
||||
opts = append(opts, WithLLMAPIKey(effectiveAPIKey))
|
||||
}
|
||||
xlog.Info("Creating agent (no Run)", "name", name, "model", model, "api_url", effectiveAPIURL)
|
||||
|
||||
agent, err := New(opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a.agents[name] = agent
|
||||
a.managers[name] = manager
|
||||
|
||||
// Start the conversation consumer so ConversationAction doesn't deadlock.
|
||||
// This is normally started by Run(), but we skip Run() in distributed mode.
|
||||
agent.StartConversationConsumer()
|
||||
|
||||
xlog.Info("Agent created (no Run)", "name", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *AgentPool) stateFiles(name string) (string, string) {
|
||||
stateFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.state.json", name))
|
||||
characterFile := filepath.Join(a.pooldir, fmt.Sprintf("%s.character.json", name))
|
||||
@@ -884,6 +690,9 @@ 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
|
||||
}
|
||||
@@ -901,21 +710,7 @@ func (a *AgentPool) save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := a.file + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, a.file); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
bakPath := a.file + ".bak"
|
||||
if err := os.WriteFile(bakPath, data, 0644); err != nil {
|
||||
// best-effort; main file is already good
|
||||
xlog.Warn("Failed to write pool backup", "path", bakPath, "error", err)
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(a.file, data, 0644)
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetAgent(name string) *Agent {
|
||||
@@ -944,9 +739,8 @@ func (a *AgentPool) GetConfig(name string) *AgentConfig {
|
||||
return &agent
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetManager(name string) sseLib.Manager {
|
||||
func (a *AgentPool) GetManager(name string) sse.Manager {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
return a.managers[name]
|
||||
}
|
||||
|
||||
|
||||
@@ -102,17 +102,16 @@ func (c *cogitoWrapper) Tool() openai.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cogitoWrapper) Execute(args map[string]any) (string, any, error) {
|
||||
func (c *cogitoWrapper) Run(args map[string]any) (string, 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 "", err
|
||||
}
|
||||
|
||||
return result.Result, result, nil
|
||||
return result.Result, nil
|
||||
}
|
||||
|
||||
// Actions is something the agent can do
|
||||
@@ -188,8 +187,8 @@ func (a Actions) ToTools() []openai.Tool {
|
||||
return tools
|
||||
}
|
||||
|
||||
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.ToolDefinitionInterface {
|
||||
tools := []cogito.ToolDefinitionInterface{}
|
||||
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.Tool {
|
||||
tools := []cogito.Tool{}
|
||||
for _, action := range a {
|
||||
tools = append(tools, &cogitoWrapper{action: action, ctx: ctx, sharedState: sharedState})
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
}
|
||||
+1
-6
@@ -9,11 +9,6 @@ import (
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// MetadataKeyConversationID is the job metadata key for per-conversation identity.
|
||||
// When set (e.g. "slack:CHANNEL_ID", "telegram:CHAT_ID"), the agent may cancel the
|
||||
// currently running job for that conversation before enqueueing a new one.
|
||||
const MetadataKeyConversationID = "conversation_id"
|
||||
|
||||
// Job is a request to the agent to do something
|
||||
type Job struct {
|
||||
// The job is a request to the agent to do something
|
||||
@@ -82,7 +77,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
|
||||
}
|
||||
}
|
||||
|
||||
func WithMetadata(metadata map[string]any) JobOption {
|
||||
func WithMetadata(metadata map[string]interface{}) JobOption {
|
||||
return func(j *Job) {
|
||||
j.Metadata = metadata
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/cogito"
|
||||
@@ -31,7 +30,7 @@ func (j *JobResult) SetResult(text ActionState) {
|
||||
j.State = append(j.State, text)
|
||||
}
|
||||
|
||||
// Finish marks the job as done and closes the ready channel.
|
||||
// SetResult sets the result of a job
|
||||
func (j *JobResult) Finish(e error) {
|
||||
j.Lock()
|
||||
j.Error = e
|
||||
@@ -62,13 +61,9 @@ func (j *JobResult) SetResponse(response string) {
|
||||
}
|
||||
|
||||
// WaitResult waits for the result of a job
|
||||
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-j.ready:
|
||||
}
|
||||
func (j *JobResult) WaitResult() *JobResult {
|
||||
<-j.ready
|
||||
j.Lock()
|
||||
defer j.Unlock()
|
||||
return j, nil
|
||||
return j
|
||||
}
|
||||
|
||||
+8
-27
@@ -5,19 +5,8 @@ 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
|
||||
@@ -40,26 +29,17 @@ const (
|
||||
DefaultLastMessageDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
|
||||
type RecurringReminderParams struct {
|
||||
Message string `json:"message"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
|
||||
AgentName string `json:"agent_name"`
|
||||
Reminders []ReminderActionResponse `json:"reminders"`
|
||||
}
|
||||
|
||||
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
@@ -68,6 +48,7 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
|
||||
}
|
||||
return &AgentSharedState{
|
||||
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
|
||||
Reminders: make([]ReminderActionResponse, 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -11,16 +11,21 @@ services:
|
||||
- /dev/dri
|
||||
- /dev/kfd
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -12,16 +12,21 @@ services:
|
||||
- /dev/dri/card1
|
||||
- /dev/dri/renderD129
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
@@ -17,16 +17,21 @@ services:
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: postgres
|
||||
|
||||
dind:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: dind
|
||||
|
||||
localrecall:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall
|
||||
|
||||
localrecall-healthcheck:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
service: localrecall-healthcheck
|
||||
|
||||
localagi:
|
||||
extends:
|
||||
file: docker-compose.yaml
|
||||
|
||||
+28
-36
@@ -8,7 +8,8 @@ services:
|
||||
image: localai/localai:master
|
||||
command:
|
||||
- ${MODEL_NAME:-gemma-3-4b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
|
||||
- ${MULTIMODAL_MODEL:-moondream2-20250414}
|
||||
- ${IMAGE_MODEL:-sd-1.5-ggml}
|
||||
- granite-embedding-107m-multilingual
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
|
||||
@@ -21,25 +22,30 @@ services:
|
||||
- DEBUG=true
|
||||
#- LOCALAI_API_KEY=sk-1234567890
|
||||
volumes:
|
||||
- models:/models
|
||||
- backends:/backends
|
||||
- images:/tmp/generated/images
|
||||
- ./volumes/models:/models
|
||||
- ./volumes/backends:/backends
|
||||
- ./volumes/content:/tmp/generated/content
|
||||
|
||||
postgres:
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=localrecall
|
||||
- POSTGRES_USER=localrecall
|
||||
- POSTGRES_PASSWORD=localrecall
|
||||
localrecall:
|
||||
image: quay.io/mudler/localrecall:main
|
||||
ports:
|
||||
- 5432:5432
|
||||
- 8080
|
||||
environment:
|
||||
- COLLECTION_DB_PATH=/db
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- FILE_ASSETS=/assets
|
||||
- OPENAI_API_KEY=sk-1234567890
|
||||
- OPENAI_BASE_URL=http://localai:8080
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- ./volumes/localrag/db:/db
|
||||
- ./volumes/localrag/assets/:/assets
|
||||
|
||||
localrecall-healthcheck:
|
||||
depends_on:
|
||||
localrecall:
|
||||
condition: service_started
|
||||
image: busybox
|
||||
command: ["sh", "-c", "until wget -q -O - http://localrecall:8080 > /dev/null 2>&1; do echo 'Waiting for localrecall...'; sleep 1; done; echo 'localrecall is up!'"]
|
||||
|
||||
sshbox:
|
||||
build:
|
||||
@@ -58,11 +64,8 @@ services:
|
||||
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
|
||||
@@ -73,8 +76,8 @@ services:
|
||||
depends_on:
|
||||
localai:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
@@ -86,13 +89,11 @@ 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
|
||||
# Knowledge base (collections) with PostgreSQL by default
|
||||
- VECTOR_ENGINE=postgres
|
||||
- DATABASE_URL=postgresql://localrecall:localrecall@postgres:5432/localrecall?sslmode=disable
|
||||
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
|
||||
- LOCALAGI_TIMEOUT=5m
|
||||
- LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
- LOCALAGI_SSHBOX_URL=root:root@sshbox:22
|
||||
@@ -100,13 +101,4 @@ services:
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- localagi_pool:/pool
|
||||
# Optional: mount a host directory for skills (replaces the default state-dir/skills path)
|
||||
# - ./skills:/pool/skills
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
models:
|
||||
backends:
|
||||
images:
|
||||
localagi_pool:
|
||||
- ./volumes/localagi/:/pool
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
module github.com/mudler/LocalAGI
|
||||
|
||||
go 1.26.0
|
||||
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/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.11
|
||||
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/jung-kurt/gofpdf v1.16.2
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b
|
||||
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
|
||||
github.com/mudler/xlog v0.0.5
|
||||
github.com/onsi/ginkgo/v2 v2.28.1
|
||||
github.com/onsi/gomega v1.39.1
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0
|
||||
github.com/mudler/cogito v0.4.2
|
||||
github.com/onsi/ginkgo/v2 v2.25.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/philippgille/chromem-go v0.7.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sashabaranov/go-openai v1.41.2
|
||||
github.com/slack-go/slack v0.17.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64
|
||||
github.com/tmc/langchaingo v0.1.14
|
||||
github.com/traefik/yaegi v0.16.1
|
||||
github.com/valyala/fasthttp v1.68.0
|
||||
golang.org/x/crypto v0.50.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
|
||||
@@ -43,71 +35,16 @@ require (
|
||||
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/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // 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/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-git/v5 v5.16.4 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.1.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jolestar/go-commons-pool/v2 v2.1.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klippa-app/go-pdfium v1.19.2 // 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/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tetratelabs/wazero v1.11.0 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // 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/mod v0.34.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -132,7 +69,7 @@ require (
|
||||
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-20260115054156-294ebfa9ad83 // indirect
|
||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
|
||||
github.com/kennygrant/sanitize v1.2.4 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
@@ -152,12 +89,13 @@ require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // 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/exp v0.0.0-20240808152545-0cdaa3abc0fa
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
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.10 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
)
|
||||
|
||||
@@ -12,21 +12,14 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
|
||||
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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
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/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/antchfx/htmlquery v1.3.4 h1:Isd0srPkni2iNTWCwVj/72t7uCphFeor5Q8nCzj1jdQ=
|
||||
github.com/antchfx/htmlquery v1.3.4/go.mod h1:K9os0BwIEmLAvTqaNSua8tXLWRWZpocZIH73OzWQbwM=
|
||||
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
|
||||
@@ -34,48 +27,6 @@ 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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
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/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
@@ -83,8 +34,6 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
|
||||
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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
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=
|
||||
@@ -96,17 +45,11 @@ github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7np
|
||||
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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
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/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=
|
||||
@@ -119,8 +62,6 @@ 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/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
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=
|
||||
@@ -129,32 +70,12 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4=
|
||||
github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
|
||||
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
|
||||
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/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
|
||||
github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
|
||||
github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
|
||||
github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
|
||||
github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE=
|
||||
github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
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-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
@@ -169,23 +90,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-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31HkWs=
|
||||
github.com/gofiber/fiber/v2 v2.52.11/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-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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=
|
||||
@@ -193,8 +104,6 @@ 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=
|
||||
@@ -206,11 +115,10 @@ github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzea
|
||||
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
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=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
@@ -218,48 +126,18 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jolestar/go-commons-pool/v2 v2.1.2 h1:E+XGo58F23t7HtZiC/W6jzO2Ux2IccSH/yx4nD+J1CM=
|
||||
github.com/jolestar/go-commons-pool/v2 v2.1.2/go.mod h1:r4NYccrkS5UqP1YQI1COyTZ9UjPJAAGTUxzcsK1kqhY=
|
||||
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
|
||||
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
|
||||
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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
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/klippa-app/go-pdfium v1.19.2 h1:Gc/OT7wVO7xStNlDR5o/Qz0T/tsVtODsh7I1vOJXIKU=
|
||||
github.com/klippa-app/go-pdfium v1.19.2/go.mod h1:X+AMQDw/TXTsgiY2vEGA7oYlQTmjyqmlt6pm6aoIDa0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
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/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
|
||||
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
|
||||
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=
|
||||
@@ -270,8 +148,6 @@ 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/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
|
||||
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
|
||||
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=
|
||||
@@ -290,43 +166,24 @@ 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.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.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/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
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.9.5-0.20260315222927-63abdec7189b h1:A74T2Lauvg61KodYqsjTYDY05kPLcW+efVZjd23dghU=
|
||||
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32 h1:RP4BVGTHHpJIrGAwqRD3Wq1wmURmc1SxhwacnIWgI+g=
|
||||
github.com/mudler/localrecall v0.6.3-0.20260618142827-d0073dd5dc32/go.mod h1:28k5n19raUrkuwXkacdNsBlj8yuSnGhpT16tu+2+4dU=
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49 h1:dAF1ALXqqapRZo80x56BIBBcPrPbRNerbd66rdyO8J4=
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49/go.mod h1:z3yFhcL9bSykmmh6xgGu0hyoItd4CnxgtWMEWw8uFJU=
|
||||
github.com/mudler/xlog v0.0.5 h1:2unBuVC5rNGhCC86UaA94TElWFml80NL5XLK+kAmNuU=
|
||||
github.com/mudler/xlog v0.0.5/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
|
||||
github.com/mudler/cogito v0.4.2 h1:1PypFvUq3A+iBxMCR0v+sGXlhOsRnvyFZhEE93tsuDU=
|
||||
github.com/mudler/cogito v0.4.2/go.mod h1:2uhEElCTq8eXSsqJ1JF01oA5h9niXSELVKqCF1PqjEw=
|
||||
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.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
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/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/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 h1:2vmb32OdDhjZf2ETGDlr9n8RYXx7c+jXPxMiPbwnA+8=
|
||||
github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4/go.mod h1:2JQx4jDHmWrbABvpOayg/+OTU6ehN0IyK2EHzceXpJo=
|
||||
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/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
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=
|
||||
@@ -336,18 +193,18 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
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=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a h1:w3tdWGKbLGBPtR/8/oO74W6hmz0qE5q0z9aqSAewaaM=
|
||||
github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:S8kfXMp+yh77OxPD4fdM6YUknrZpQxLhvxzS4gDHENY=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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=
|
||||
@@ -360,37 +217,24 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU
|
||||
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.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
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/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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.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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
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.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/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
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=
|
||||
@@ -415,8 +259,6 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
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/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
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=
|
||||
@@ -426,8 +268,6 @@ 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=
|
||||
@@ -442,32 +282,29 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
|
||||
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/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=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
@@ -475,8 +312,8 @@ 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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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=
|
||||
@@ -486,16 +323,11 @@ 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -505,8 +337,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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
@@ -516,8 +348,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.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
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=
|
||||
@@ -529,35 +361,29 @@ 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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
|
||||
@@ -1,9 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/cmd"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/webui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
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")
|
||||
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 customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR")
|
||||
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
|
||||
|
||||
func init() {
|
||||
if baseModel == "" {
|
||||
panic("LOCALAGI_MODEL not set")
|
||||
}
|
||||
if apiURL == "" {
|
||||
panic("LOCALAGI_LLM_API_URL not set")
|
||||
}
|
||||
if timeout == "" {
|
||||
timeout = "5m"
|
||||
}
|
||||
if stateDir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
stateDir = filepath.Join(cwd, "pool")
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
// make sure state dir exists
|
||||
os.MkdirAll(stateDir, 0755)
|
||||
|
||||
apiKeys := []string{}
|
||||
if apiKeysEnv != "" {
|
||||
apiKeys = strings.Split(apiKeysEnv, ",")
|
||||
}
|
||||
|
||||
// Create the agent pool
|
||||
pool, err := state.NewAgentPool(
|
||||
baseModel,
|
||||
multimodalModel,
|
||||
transcriptionModel,
|
||||
transcriptionLanguage,
|
||||
ttsModel,
|
||||
imageModel,
|
||||
apiURL,
|
||||
apiKey,
|
||||
stateDir,
|
||||
localRAG,
|
||||
services.Actions(map[string]string{
|
||||
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
|
||||
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
|
||||
services.ActionConfigSSHBoxURL: sshBoxURL,
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
}),
|
||||
services.Connectors,
|
||||
services.DynamicPrompts(map[string]string{
|
||||
services.ConfigStateDir: stateDir,
|
||||
services.CustomActionsDir: customActionsDir,
|
||||
}),
|
||||
services.Filters,
|
||||
timeout,
|
||||
withLogs,
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create the application
|
||||
app := webui.NewApp(
|
||||
webui.WithPool(pool),
|
||||
webui.WithConversationStoreduration(conversationDuration),
|
||||
webui.WithApiKeys(apiKeys...),
|
||||
webui.WithLLMAPIUrl(apiURL),
|
||||
webui.WithLLMAPIKey(apiKey),
|
||||
webui.WithLLMModel(baseModel),
|
||||
webui.WithCustomActionsDir(customActionsDir),
|
||||
webui.WithStateDir(stateDir),
|
||||
)
|
||||
|
||||
// Start the agents
|
||||
if err := pool.StartAll(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Start the web server
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
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
|
||||
}
|
||||
+59
-303
@@ -11,14 +11,12 @@ import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
var _ agent.RAGDB = &WrappedClient{}
|
||||
@@ -28,8 +26,7 @@ type WrappedClient struct {
|
||||
collection string
|
||||
}
|
||||
|
||||
func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
|
||||
collection := strings.TrimSpace(strings.ToLower(c))
|
||||
func NewWrappedClient(baseURL, apiKey, collection string) *WrappedClient {
|
||||
wc := &WrappedClient{
|
||||
Client: NewClient(baseURL, apiKey),
|
||||
collection: collection,
|
||||
@@ -40,11 +37,6 @@ func NewWrappedClient(baseURL, apiKey, c 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 {
|
||||
@@ -93,39 +85,7 @@ func (c *WrappedClient) Store(s string) error {
|
||||
}
|
||||
|
||||
defer os.Remove(f)
|
||||
_, err = c.Client.Store(c.collection, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// 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))
|
||||
return c.Client.Store(c.collection, f)
|
||||
}
|
||||
|
||||
// Result represents a single result from a query.
|
||||
@@ -141,13 +101,6 @@ 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
|
||||
@@ -198,8 +151,7 @@ func (c *Client) CreateCollection(name string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to create collection")
|
||||
return errors.New("failed to create collection")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -222,30 +174,17 @@ func (c *Client) ListCollections() ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list collections")
|
||||
}
|
||||
|
||||
var collections []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&collections)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return collections, nil
|
||||
}
|
||||
|
||||
// ListEntries lists all entries in a collection
|
||||
@@ -265,79 +204,17 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to list entries")
|
||||
}
|
||||
|
||||
var entries []string
|
||||
err = json.NewDecoder(resp.Body).Decode(&entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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"`
|
||||
Keys []string `json:"keys"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Keys) > 0 {
|
||||
return data.Keys, nil
|
||||
}
|
||||
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
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// DeleteEntry deletes an entry in a collection
|
||||
@@ -367,30 +244,19 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Search searches a collection
|
||||
@@ -421,30 +287,17 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("failed to search collection")
|
||||
}
|
||||
|
||||
var results []Result
|
||||
err = json.NewDecoder(resp.Body).Decode(&results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Reset resets a collection
|
||||
@@ -465,20 +318,21 @@ func (c *Client) Reset(collection string) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, body, "failed to reset collection")
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
return errors.New("failed to reset collection: " + b.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store uploads a file to a collection and returns the assigned entry key.
|
||||
func (c *Client) Store(collection, filePath string) (string, error) {
|
||||
// Store uploads a file to a collection
|
||||
func (c *Client) Store(collection, filePath string) error {
|
||||
url := fmt.Sprintf("%s/api/collections/%s/upload", c.BaseURL, collection)
|
||||
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -487,22 +341,22 @@ func (c *Client) Store(collection, filePath string) (string, error) {
|
||||
|
||||
part, err := writer.CreateFormFile("file", file.Name())
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
c.addAuthHeader(req)
|
||||
@@ -510,124 +364,26 @@ func (c *Client) Store(collection, filePath string) (string, error) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", parseAPIError(resp, body, "failed to upload file")
|
||||
}
|
||||
b := new(bytes.Buffer)
|
||||
b.ReadFrom(resp.Body)
|
||||
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Filename string `json:"filename"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", nil // upload succeeded, can't parse key
|
||||
}
|
||||
return result.Key, nil
|
||||
}
|
||||
|
||||
// SourceInfo represents an external source for a collection (LocalRecall API contract).
|
||||
type SourceInfo struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"` // minutes
|
||||
LastUpdate string `json:"last_update"` // RFC3339
|
||||
}
|
||||
|
||||
// AddSource registers an external source for a collection.
|
||||
func (c *Client) AddSource(collection, url string, updateIntervalMinutes int) error {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
var body struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"`
|
||||
}
|
||||
body.URL = url
|
||||
body.UpdateInterval = updateIntervalMinutes
|
||||
if body.UpdateInterval < 1 {
|
||||
body.UpdateInterval = 60
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, b, "failed to add source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSource removes an external source from a collection.
|
||||
func (c *Client) RemoveSource(collection, url string) error {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
payload, err := json.Marshal(map[string]string{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodDelete, reqURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return parseAPIError(resp, b, "failed to remove source")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSources returns external sources for a collection.
|
||||
func (c *Client) ListSources(collection string) ([]SourceInfo, error) {
|
||||
reqURL := fmt.Sprintf("%s/api/collections/%s/sources", c.BaseURL, collection)
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.addAuthHeader(req)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, parseAPIError(resp, body, "failed to list sources")
|
||||
}
|
||||
var wrap apiResponse
|
||||
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
|
||||
if wrap.Error != nil {
|
||||
return nil, errors.New(wrap.Error.Message)
|
||||
type response struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
return nil, fmt.Errorf("invalid response: %w", err)
|
||||
|
||||
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")
|
||||
}
|
||||
var data struct {
|
||||
Sources []SourceInfo `json:"sources"`
|
||||
}
|
||||
if err := json.Unmarshal(wrap.Data, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data.Sources, nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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...)
|
||||
}
|
||||
+32
-48
@@ -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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
)
|
||||
@@ -22,6 +22,8 @@ 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"
|
||||
@@ -45,21 +47,16 @@ 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"
|
||||
)
|
||||
@@ -82,6 +79,8 @@ var AvailableActions = []string{
|
||||
ActionGithubGetAllContent,
|
||||
ActionGithubRepositorySearchFiles,
|
||||
ActionGithubRepositoryListFiles,
|
||||
ActionBrowserAgentRunner,
|
||||
ActionDeepResearchRunner,
|
||||
ActionGithubRepositoryCreateOrUpdate,
|
||||
ActionGithubIssueReader,
|
||||
ActionGithubIssueCommenter,
|
||||
@@ -95,8 +94,6 @@ var AvailableActions = []string{
|
||||
ActionWikipedia,
|
||||
ActionSendMail,
|
||||
ActionGenerateImage,
|
||||
ActionGenerateSong,
|
||||
ActionGeneratePDF,
|
||||
ActionTwitterPost,
|
||||
ActionCounter,
|
||||
ActionCallAgents,
|
||||
@@ -108,7 +105,6 @@ var AvailableActions = []string{
|
||||
ActionAddToMemory,
|
||||
ActionListMemory,
|
||||
ActionRemoveFromMemory,
|
||||
ActionSearchMemory,
|
||||
ActionPiKVMPowerControl,
|
||||
ActionWebhook,
|
||||
}
|
||||
@@ -119,21 +115,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",
|
||||
@@ -149,11 +145,6 @@ 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",
|
||||
@@ -290,13 +281,8 @@ var DefaultActions = []config.FieldGroup{
|
||||
Fields: actions.SendTelegramMessageConfigMeta(),
|
||||
},
|
||||
{
|
||||
Name: "set_recurring_reminder",
|
||||
Label: "Set Recurring Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
Name: "set_onetime_reminder",
|
||||
Label: "Set One-Time Reminder",
|
||||
Name: "set_reminder",
|
||||
Label: "Set Reminder",
|
||||
Fields: []config.Field{},
|
||||
},
|
||||
{
|
||||
@@ -322,9 +308,11 @@ var DefaultActions = []config.FieldGroup{
|
||||
}
|
||||
|
||||
const (
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ConfigStateDir = "state-dir"
|
||||
CustomActionsDir = "custom-actions-dir"
|
||||
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
|
||||
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
|
||||
ActionConfigSSHBoxURL = "sshbox-url"
|
||||
ConfigStateDir = "state-dir"
|
||||
CustomActionsDir = "custom-actions-dir"
|
||||
)
|
||||
|
||||
func customActions(customActionsDir string, existingActionConfigs map[string]map[string]string) (allActions []types.Action) {
|
||||
@@ -412,17 +400,13 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
|
||||
config = map[string]string{}
|
||||
}
|
||||
|
||||
memoryIdxPath := memoryIndexPath(agentName, actionsConfigs)
|
||||
memoryFilePath := memoryPath(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:
|
||||
@@ -435,6 +419,10 @@ 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:
|
||||
@@ -479,22 +467,18 @@ 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 ActionSetRecurringReminder:
|
||||
a = action.NewRecurringReminder()
|
||||
case ActionSetOneTimeReminder:
|
||||
a = action.NewOneTimeReminder()
|
||||
case ActionSetReminder:
|
||||
a = action.NewReminder()
|
||||
case ActionListReminders:
|
||||
a = action.NewListReminders()
|
||||
case ActionRemoveReminder:
|
||||
a = action.NewRemoveReminder()
|
||||
case ActionAddToMemory:
|
||||
a, _, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
a, _, _ = actions.NewMemoryActions(memoryFilePath, config)
|
||||
case ActionListMemory:
|
||||
_, a, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
_, a, _ = actions.NewMemoryActions(memoryFilePath, config)
|
||||
case ActionRemoveFromMemory:
|
||||
_, _, a, _ = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
case ActionSearchMemory:
|
||||
_, _, _, a = actions.NewMemoryActions(memoryIdxPath, config)
|
||||
_, _, a = actions.NewMemoryActions(memoryFilePath, config)
|
||||
case ActionPiKVMPowerControl:
|
||||
a = actions.NewPiKVMAction(config)
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"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()
|
||||
tr := pdf.UnicodeTranslatorFromDescriptor("")
|
||||
|
||||
// Add title if provided
|
||||
if result.Title != "" {
|
||||
pdf.SetFont("Arial", "B", 16)
|
||||
pdf.MultiCell(0, 10, tr(result.Title), "", "", false)
|
||||
pdf.Ln(5)
|
||||
}
|
||||
|
||||
// Add content: parse as markdown and render, or fall back to plain text
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions)
|
||||
doc := p.Parse([]byte(result.Content))
|
||||
if doc != nil && ast.GetFirstChild(doc) != nil {
|
||||
renderMarkdownToPDF(pdf, tr, doc)
|
||||
} else {
|
||||
pdf.MultiCell(0, 10, tr(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 or Markdown content to include in the PDF (headings, bold, lists, code blocks, etc. are rendered)",
|
||||
},
|
||||
"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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gomarkdown/markdown/ast"
|
||||
"github.com/jung-kurt/gofpdf"
|
||||
)
|
||||
|
||||
const (
|
||||
pdfLineHeight = 6.0
|
||||
pdfBlockMargin = 4.0
|
||||
)
|
||||
|
||||
// renderMarkdownToPDF walks the markdown AST and renders it to the PDF using tr for all text.
|
||||
func renderMarkdownToPDF(pdf *gofpdf.Fpdf, tr func(string) string, doc ast.Node) {
|
||||
for child := ast.GetFirstChild(doc); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
}
|
||||
|
||||
func renderBlock(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Document:
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
case *ast.Heading:
|
||||
level := n.Level
|
||||
if level > 6 {
|
||||
level = 6
|
||||
}
|
||||
size := float64(22 - level*2)
|
||||
if size < 12 {
|
||||
size = 12
|
||||
}
|
||||
pdf.SetFont("Arial", "B", size)
|
||||
writeInlineContent(pdf, tr, n)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Paragraph:
|
||||
writeInlineContent(pdf, tr, n)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
case *ast.List:
|
||||
listType := n.ListFlags
|
||||
ordered := (listType & ast.ListTypeOrdered) != 0
|
||||
start := n.Start
|
||||
if start <= 0 {
|
||||
start = 1
|
||||
}
|
||||
itemNum := 0
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
if item, ok := child.(*ast.ListItem); ok {
|
||||
itemNum++
|
||||
var bullet string
|
||||
if ordered {
|
||||
bullet = tr(fmt.Sprintf("%d. ", start+itemNum-1))
|
||||
} else {
|
||||
bullet = tr("• ")
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.CellFormat(8, pdfLineHeight, bullet, "", 0, "", false, 0, "")
|
||||
for inner := ast.GetFirstChild(item); inner != nil; inner = ast.GetNextNode(inner) {
|
||||
renderBlock(pdf, tr, inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.ListItem:
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
case *ast.CodeBlock:
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.BlockQuote:
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
saveLeft := left
|
||||
pdf.SetLeftMargin(saveLeft + 4)
|
||||
pdf.SetX(saveLeft + 4)
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
pdf.SetLeftMargin(saveLeft)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.HorizontalRule:
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
pdf.Line(pdf.GetX(), pdf.GetY(), pdf.GetX()+190, pdf.GetY())
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.Table:
|
||||
renderTable(pdf, tr, n)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.MathBlock:
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.HTMLBlock:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 9)
|
||||
pdf.MultiCell(0, pdfLineHeight-1, tr(string(lit)), "", "", false)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
case *ast.Aside:
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
saveLeft := left
|
||||
pdf.SetLeftMargin(saveLeft + 4)
|
||||
pdf.SetX(saveLeft + 4)
|
||||
for child := ast.GetFirstChild(n); child != nil; child = ast.GetNextNode(child) {
|
||||
renderBlock(pdf, tr, child)
|
||||
}
|
||||
pdf.SetLeftMargin(saveLeft)
|
||||
pdf.Ln(pdfBlockMargin)
|
||||
default:
|
||||
// Unknown block: try to render as inline content (e.g. paragraph-like)
|
||||
if ast.GetFirstChild(node) != nil {
|
||||
writeInlineContent(pdf, tr, node)
|
||||
pdf.Ln(pdfLineHeight + pdfBlockMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
pdfTableLineHt = 7.0
|
||||
pdfTableHeaderR = 72
|
||||
pdfTableHeaderG = 72
|
||||
pdfTableHeaderB = 72
|
||||
pdfTableBorderR = 200
|
||||
pdfTableBorderG = 200
|
||||
pdfTableBorderB = 200
|
||||
pdfTableStripR = 248
|
||||
pdfTableStripG = 248
|
||||
pdfTableStripB = 248
|
||||
)
|
||||
|
||||
// renderTable draws a markdown table. Table contains TableHeader and TableBody, each with TableRows of TableCells.
|
||||
func renderTable(pdf *gofpdf.Fpdf, tr func(string) string, table *ast.Table) {
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
pageW := 210.0
|
||||
tblW := pageW - left - right
|
||||
|
||||
// Collect all rows: header rows first, then body (and footer if any)
|
||||
var rows [][]string
|
||||
var numCols int
|
||||
for section := ast.GetFirstChild(table); section != nil; section = ast.GetNextNode(section) {
|
||||
for rowNode := ast.GetFirstChild(section); rowNode != nil; rowNode = ast.GetNextNode(rowNode) {
|
||||
row, ok := rowNode.(*ast.TableRow)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var cells []string
|
||||
for c := ast.GetFirstChild(row); c != nil; c = ast.GetNextNode(c) {
|
||||
if cell, ok := c.(*ast.TableCell); ok {
|
||||
cells = append(cells, tr(getCellText(cell)))
|
||||
}
|
||||
}
|
||||
if len(cells) > 0 {
|
||||
rows = append(rows, cells)
|
||||
if len(cells) > numCols {
|
||||
numCols = len(cells)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if numCols == 0 {
|
||||
return
|
||||
}
|
||||
colW := tblW / float64(numCols)
|
||||
lineHt := pdfTableLineHt
|
||||
|
||||
// Save current colors and set light gray borders for the table
|
||||
saveDrawR, saveDrawG, saveDrawB := pdf.GetDrawColor()
|
||||
saveFillR, saveFillG, saveFillB := pdf.GetFillColor()
|
||||
saveTextR, saveTextG, saveTextB := pdf.GetTextColor()
|
||||
pdf.SetDrawColor(pdfTableBorderR, pdfTableBorderG, pdfTableBorderB)
|
||||
|
||||
for i, row := range rows {
|
||||
isHeader := i == 0
|
||||
lastRow := i == len(rows) - 1
|
||||
// Header: dark gray background, white text, bold
|
||||
if isHeader {
|
||||
pdf.SetFont("Arial", "B", 12)
|
||||
pdf.SetFillColor(pdfTableHeaderR, pdfTableHeaderG, pdfTableHeaderB)
|
||||
pdf.SetTextColor(255, 255, 255)
|
||||
} else {
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.SetTextColor(0, 0, 0)
|
||||
if i%2 == 1 {
|
||||
pdf.SetFillColor(pdfTableStripR, pdfTableStripG, pdfTableStripB)
|
||||
} else {
|
||||
pdf.SetFillColor(255, 255, 255)
|
||||
}
|
||||
}
|
||||
border := "LTR"
|
||||
if lastRow {
|
||||
border = "LTRB"
|
||||
}
|
||||
fill := true
|
||||
for j, cellText := range row {
|
||||
w := colW
|
||||
if j == numCols-1 {
|
||||
w = 0
|
||||
}
|
||||
pdf.CellFormat(w, lineHt, cellText, border, 0, "L", fill, 0, "")
|
||||
}
|
||||
pdf.Ln(lineHt)
|
||||
}
|
||||
|
||||
// Restore colors and font
|
||||
pdf.SetDrawColor(saveDrawR, saveDrawG, saveDrawB)
|
||||
pdf.SetFillColor(saveFillR, saveFillG, saveFillB)
|
||||
pdf.SetTextColor(saveTextR, saveTextG, saveTextB)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
|
||||
// getInlineText returns plain text from an inline container (e.g. Image alt text).
|
||||
func getInlineText(node ast.Node) string {
|
||||
var b []byte
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
b = append(b, leaf.Literal...)
|
||||
} else if text, ok := child.(*ast.Text); ok {
|
||||
lit := text.Literal
|
||||
if lit == nil {
|
||||
lit = text.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
b = append(b, lit...)
|
||||
}
|
||||
} else {
|
||||
b = append(b, getInlineText(child)...)
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// getCellText returns plain text from a table cell (walks Paragraph/Text and Leaf nodes).
|
||||
func getCellText(node ast.Node) string {
|
||||
var b []byte
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
if leaf, ok := child.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
b = append(b, leaf.Literal...)
|
||||
} else if text, ok := child.(*ast.Text); ok {
|
||||
lit := text.Literal
|
||||
if lit == nil {
|
||||
lit = text.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
b = append(b, lit...)
|
||||
}
|
||||
} else {
|
||||
b = append(b, getCellText(child)...)
|
||||
}
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// writeInlineContent outputs inline content (text, strong, emph, code) with correct font changes.
|
||||
func writeInlineContent(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node) {
|
||||
lineHt := pdfLineHeight
|
||||
left, _, right, _ := pdf.GetMargins()
|
||||
pageW := 210.0 // A4 mm
|
||||
maxW := pageW - left - right
|
||||
|
||||
for child := ast.GetFirstChild(node); child != nil; child = ast.GetNextNode(child) {
|
||||
writeInline(pdf, tr, child, lineHt, maxW)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInline(pdf *gofpdf.Fpdf, tr func(string) string, node ast.Node, lineHt, maxW float64) {
|
||||
switch n := node.(type) {
|
||||
case *ast.Text:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
}
|
||||
case *ast.Strong:
|
||||
pdf.SetFont("Arial", "B", 12)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Emph:
|
||||
pdf.SetFont("Arial", "I", 12)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Code:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 11)
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Link:
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
if len(n.Destination) > 0 {
|
||||
pdf.SetFont("Arial", "I", 10)
|
||||
cellWrap(pdf, tr(" ("+string(n.Destination)+")"), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Image:
|
||||
alt := getInlineText(n)
|
||||
if alt != "" {
|
||||
cellWrap(pdf, tr(alt), lineHt, maxW)
|
||||
}
|
||||
if len(n.Destination) > 0 {
|
||||
pdf.SetFont("Arial", "I", 10)
|
||||
cellWrap(pdf, tr(" [Image: "+string(n.Destination)+"]"), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Del:
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
case *ast.Subscript:
|
||||
pdf.SetFont("Arial", "", 9)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Superscript:
|
||||
pdf.SetFont("Arial", "", 9)
|
||||
for c := ast.GetFirstChild(n); c != nil; c = ast.GetNextNode(c) {
|
||||
writeInline(pdf, tr, c, lineHt, maxW)
|
||||
}
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
case *ast.Math:
|
||||
lit := n.Literal
|
||||
if lit == nil {
|
||||
lit = n.Content
|
||||
}
|
||||
if len(lit) > 0 {
|
||||
pdf.SetFont("Courier", "", 10)
|
||||
cellWrap(pdf, tr(string(lit)), lineHt, maxW)
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
}
|
||||
case *ast.Hardbreak:
|
||||
pdf.Ln(lineHt)
|
||||
case *ast.Softbreak:
|
||||
pdf.Ln(lineHt)
|
||||
default:
|
||||
if leaf, ok := node.(*ast.Leaf); ok && len(leaf.Literal) > 0 {
|
||||
cellWrap(pdf, tr(string(leaf.Literal)), lineHt, maxW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cellWrap outputs text with word-wrap: splits on spaces and starts a new line when the next word would overflow.
|
||||
func cellWrap(pdf *gofpdf.Fpdf, s string, lineHt, maxW float64) {
|
||||
left, _, _, _ := pdf.GetMargins()
|
||||
words := strings.Fields(s)
|
||||
for i, word := range words {
|
||||
wordW := pdf.GetStringWidth(word)
|
||||
spaceW := 0.0
|
||||
if i > 0 {
|
||||
spaceW = pdf.GetStringWidth(" ")
|
||||
}
|
||||
x := pdf.GetX()
|
||||
// If this word (and preceding space) would overflow, start a new line first.
|
||||
if i > 0 {
|
||||
if x+spaceW+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
x = pdf.GetX()
|
||||
} else {
|
||||
pdf.CellFormat(spaceW, lineHt, " ", "", 0, "", false, 0, "")
|
||||
x = pdf.GetX()
|
||||
}
|
||||
} else if wordW > 0 && x+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
x = pdf.GetX()
|
||||
}
|
||||
// Single word longer than line width: use MultiCell so it wraps.
|
||||
if wordW > maxW-left {
|
||||
pdf.MultiCell(0, lineHt, word, "", "", false)
|
||||
} else {
|
||||
if x+wordW > maxW && x > left {
|
||||
pdf.Ln(lineHt)
|
||||
}
|
||||
pdf.CellFormat(wordW, lineHt, word, "", 0, "", false, 0, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
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))
|
||||
})
|
||||
|
||||
It("generates PDF with markdown content and renders structure", func() {
|
||||
content := "# Section\n\n**Bold** and *italic* and `code`.\n\n- Item one\n- Item two"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("generates PDF with special characters", func() {
|
||||
content := "Café, \"quotes\", 2–3"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("generates PDF with markdown table", func() {
|
||||
content := "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
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())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
})
|
||||
@@ -1,302 +0,0 @@
|
||||
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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
|
||||
+98
-279
@@ -2,29 +2,22 @@ package actions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve/v2"
|
||||
"io"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
)
|
||||
|
||||
// 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
|
||||
)
|
||||
// Remove global const and mutex, and add them as fields to a struct
|
||||
|
||||
type MemoryActions struct {
|
||||
index bleve.Index
|
||||
indexPath string
|
||||
filePath string
|
||||
customName string
|
||||
customDescription string
|
||||
}
|
||||
@@ -32,269 +25,138 @@ type MemoryActions struct {
|
||||
type AddToMemoryAction struct{ *MemoryActions }
|
||||
type ListMemoryAction struct{ *MemoryActions }
|
||||
type RemoveFromMemoryAction struct{ *MemoryActions }
|
||||
type SearchMemoryAction struct{ *MemoryActions }
|
||||
|
||||
// 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}
|
||||
// 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}
|
||||
if config != nil {
|
||||
ma.customName = config["custom_name"]
|
||||
ma.customDescription = config["custom_description"]
|
||||
}
|
||||
idx, err := openOrCreateBleveIndex(indexPath)
|
||||
if err != nil {
|
||||
// Allow lazy init: index will be nil and operations will return this error
|
||||
ma.index = nil
|
||||
} else {
|
||||
ma.index = idx
|
||||
}
|
||||
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}, &SearchMemoryAction{ma}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
m.index = idx
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}
|
||||
}
|
||||
|
||||
type addToMemoryParams struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Item string `json:"item"`
|
||||
}
|
||||
|
||||
type removeFromMemoryParams struct {
|
||||
ID string `json:"id"`
|
||||
Index *int `json:"index,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type searchMemoryParams struct {
|
||||
Query string `json:"query"`
|
||||
func (m *MemoryActions) readMemory() ([]string, error) {
|
||||
f, err := os.Open(m.filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (m *MemoryActions) writeMemory(items []string) error {
|
||||
f, err := os.Create(m.filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return json.NewEncoder(f).Encode(items)
|
||||
}
|
||||
|
||||
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.Name == "" && req.Content == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("name or content cannot both be empty")
|
||||
if req.Item == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("item cannot be empty")
|
||||
}
|
||||
entry := MemoryEntry{
|
||||
ID: generateID(),
|
||||
Name: req.Name,
|
||||
Content: req.Content,
|
||||
CreatedAt: time.Now(),
|
||||
items, err := a.readMemory()
|
||||
if 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)
|
||||
items = append(items, req.Item)
|
||||
if err := a.writeMemory(items); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
return types.ActionResult{
|
||||
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},
|
||||
Result: fmt.Sprintf("Added item to memory: %s", req.Item),
|
||||
Metadata: map[string]any{"item": req.Item, "count": len(items)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
|
||||
if err := a.ensureIndex(); err != nil {
|
||||
items, err := a.readMemory()
|
||||
if 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"})
|
||||
|
||||
searchResult, err := a.index.Search(searchRequest)
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
|
||||
outputResult := "Number of items in memory: " + strconv.Itoa(len(items)) + "\n"
|
||||
for i, item := range items {
|
||||
outputResult += fmt.Sprintf("%d) %s\n", i, item)
|
||||
}
|
||||
|
||||
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{"names": names, "entries": entries, "count": len(entries)},
|
||||
Metadata: map[string]any{"items": items},
|
||||
}, 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)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return types.ActionResult{}, fmt.Errorf("id is required to remove a memory entry")
|
||||
}
|
||||
doc, err := a.index.Document(req.ID)
|
||||
items, err := a.readMemory()
|
||||
if err != nil {
|
||||
return types.ActionResult{}, fmt.Errorf("failed to check document: %w", err)
|
||||
}
|
||||
if doc == nil {
|
||||
return types.ActionResult{}, fmt.Errorf("memory entry with ID %q not found", req.ID)
|
||||
}
|
||||
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 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
|
||||
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")
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
|
||||
e.CreatedAt = v
|
||||
}
|
||||
results = append(results, e)
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
if err := a.writeMemory(items); err != nil {
|
||||
return types.ActionResult{}, err
|
||||
}
|
||||
|
||||
return types.ActionResult{
|
||||
Result: outputResult,
|
||||
Metadata: map[string]any{"query": req.Query, "results": results, "count": len(results)},
|
||||
Result: fmt.Sprintf("Removed item from memory: %s", removed),
|
||||
Metadata: map[string]any{"removed": removed, "count": len(items)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *AddToMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "add_to_memory"
|
||||
description := "Add a new entry to memory storage (name and/or content). Stored in a Bleve index."
|
||||
description := "Add a string item to memory (stored in a JSON file)."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -305,22 +167,18 @@ func (a *AddToMemoryAction) Definition() types.ActionDefinition {
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"name": {
|
||||
"item": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The name/title of the memory entry.",
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The content to store in memory.",
|
||||
Description: "The string item to add to memory.",
|
||||
},
|
||||
},
|
||||
Required: []string{},
|
||||
Required: []string{"item"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ListMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "list_memory"
|
||||
description := "List all memory entry names."
|
||||
description := "List all items currently stored in memory."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -337,7 +195,7 @@ func (a *ListMemoryAction) Definition() types.ActionDefinition {
|
||||
|
||||
func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
|
||||
name := "remove_from_memory"
|
||||
description := "Remove a memory entry by ID."
|
||||
description := "Remove an item from memory by index or value."
|
||||
if a.customName != "" {
|
||||
name = a.customName
|
||||
}
|
||||
@@ -348,41 +206,22 @@ func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
|
||||
Name: types.ActionDefinitionName(name),
|
||||
Description: description,
|
||||
Properties: map[string]jsonschema.Definition{
|
||||
"id": {
|
||||
"index": {
|
||||
Type: jsonschema.Integer,
|
||||
Description: "The index of the item to remove (optional, 0-based)",
|
||||
},
|
||||
"value": {
|
||||
Type: jsonschema.String,
|
||||
Description: "The ID of the memory entry to remove.",
|
||||
Description: "The value of the item to remove (optional)",
|
||||
},
|
||||
},
|
||||
Required: []string{"id"},
|
||||
Required: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
func (a *AddToMemoryAction) Plannable() bool { return true }
|
||||
func (a *ListMemoryAction) Plannable() bool { return true }
|
||||
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
|
||||
|
||||
// AddToMemoryConfigMeta returns the metadata for AddToMemory action configuration fields
|
||||
func AddToMemoryConfigMeta() []config.Field {
|
||||
@@ -399,7 +238,7 @@ func AddToMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
HelpText: "Custom description for the action (optional, defaults to 'Add a string item to memory (stored in a JSON file).')",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -419,7 +258,7 @@ func ListMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
HelpText: "Custom description for the action (optional)",
|
||||
HelpText: "Custom description for the action (optional, defaults to 'List all items currently stored in memory.')",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -439,27 +278,7 @@ func RemoveFromMemoryConfigMeta() []config.Field {
|
||||
Label: "Custom Description",
|
||||
Type: config.FieldTypeText,
|
||||
Required: false,
|
||||
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)",
|
||||
HelpText: "Custom description for the action (optional, defaults to 'Remove an item from memory by index or value.')",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package actions_test
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
@@ -13,79 +12,72 @@ import (
|
||||
|
||||
var _ = Describe("MemoryActions", func() {
|
||||
var (
|
||||
tmpDir string
|
||||
indexPath string
|
||||
tmpFile string
|
||||
aAdd *actions.AddToMemoryAction
|
||||
aList *actions.ListMemoryAction
|
||||
aRemove *actions.RemoveFromMemoryAction
|
||||
aSearch *actions.SearchMemoryAction
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
tmpDir, err = os.MkdirTemp("", "memory_test_*")
|
||||
f, err := os.CreateTemp("", "memory_test_*.json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
indexPath = filepath.Join(tmpDir, "memory.bleve")
|
||||
aAdd, aList, aRemove, aSearch = actions.NewMemoryActions(indexPath, map[string]string{})
|
||||
tmpFile = f.Name()
|
||||
f.Close()
|
||||
aAdd, aList, aRemove = actions.NewMemoryActions(tmpFile, map[string]string{})
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
os.Remove(tmpFile)
|
||||
})
|
||||
|
||||
It("adds and lists entries by name", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
It("adds and lists items", func() {
|
||||
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
|
||||
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, err := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(res.Metadata["names"]).To(ContainElements("foo", "baz"))
|
||||
Expect(res.Metadata["count"]).To(Equal(2))
|
||||
Expect(res.Metadata["items"]).To(ContainElements("foo", "bar"))
|
||||
})
|
||||
|
||||
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})
|
||||
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})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
|
||||
Expect(res.Metadata["names"]).To(ConsistOf("baz"))
|
||||
Expect(res.Metadata["items"]).To(ConsistOf("bar"))
|
||||
})
|
||||
|
||||
It("returns error for missing id on remove", func() {
|
||||
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
|
||||
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"})
|
||||
_, 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())
|
||||
})
|
||||
})
|
||||
|
||||
+1
-23
@@ -3,9 +3,8 @@ package services
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
)
|
||||
|
||||
func memoryPath(agentName string, actionsConfigs map[string]string) string {
|
||||
@@ -27,24 +26,3 @@ 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"
|
||||
)
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxParamsLen is the maximum length for params in "Calling tool X with parameters: ..."
|
||||
MaxParamsLen = 400
|
||||
// MaxResultLen is the maximum length for result in "Result of X: ..."
|
||||
MaxResultLen = 500
|
||||
)
|
||||
|
||||
// StatusAccumulator holds accumulated status lines for a job's placeholder message.
|
||||
// Callers (connectors) are responsible for mutex and for clearing when the job ends.
|
||||
type StatusAccumulator struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
// NewStatusAccumulator returns a new accumulator.
|
||||
func NewStatusAccumulator() *StatusAccumulator {
|
||||
return &StatusAccumulator{lines: nil}
|
||||
}
|
||||
|
||||
// AppendReasoning appends a "Current thought: ..." line when reasoning is non-empty.
|
||||
func (a *StatusAccumulator) AppendReasoning(reasoning string) {
|
||||
if reasoning == "" {
|
||||
return
|
||||
}
|
||||
a.lines = append(a.lines, "Current thought process:\n"+reasoning)
|
||||
}
|
||||
|
||||
// AppendToolCall appends a "Calling tool X with parameters: ..." line (params truncated).
|
||||
func (a *StatusAccumulator) AppendToolCall(actionName string, params string) {
|
||||
if actionName == "" {
|
||||
actionName = "Tool"
|
||||
}
|
||||
truncated := Truncate(params, MaxParamsLen)
|
||||
a.lines = append(a.lines, fmt.Sprintf("Calling tool `%s` with parameters: %s", actionName, truncated))
|
||||
}
|
||||
|
||||
// AppendToolResult appends a "Result of X: ..." line (result truncated).
|
||||
func (a *StatusAccumulator) AppendToolResult(actionName string, result string) {
|
||||
if actionName == "" {
|
||||
actionName = "Tool"
|
||||
}
|
||||
truncated := Truncate(result, MaxResultLen)
|
||||
a.lines = append(a.lines, fmt.Sprintf("Result of `%s`: %s", actionName, truncated))
|
||||
}
|
||||
|
||||
// BuildMessage returns thinkingPrefix + "\n\n" + joined lines, truncated to maxTotalLen if needed.
|
||||
// If over the limit, the message is truncated from the start (oldest content dropped) so the latest lines stay visible.
|
||||
func (a *StatusAccumulator) BuildMessage(thinkingPrefix string, maxTotalLen int) string {
|
||||
if len(a.lines) == 0 {
|
||||
return thinkingPrefix
|
||||
}
|
||||
body := strings.Join(a.lines, "\n\n")
|
||||
full := thinkingPrefix + "\n\n" + body
|
||||
if maxTotalLen <= 0 || len(full) <= maxTotalLen {
|
||||
return full
|
||||
}
|
||||
// Keep prefix and truncate from the start of the body
|
||||
available := maxTotalLen - len(thinkingPrefix) - 2 // 2 for "\n\n"
|
||||
if available <= 0 {
|
||||
return Truncate(full, maxTotalLen)
|
||||
}
|
||||
if len(body) <= available {
|
||||
return full
|
||||
}
|
||||
// Drop oldest lines until we fit
|
||||
for i := 0; i < len(a.lines); i++ {
|
||||
trimmed := strings.Join(a.lines[i:], "\n\n")
|
||||
if len(trimmed) <= available {
|
||||
return thinkingPrefix + "\n\n" + trimmed
|
||||
}
|
||||
}
|
||||
// Single line too long
|
||||
return thinkingPrefix + "\n\n" + Truncate(body, available)
|
||||
}
|
||||
|
||||
// Truncate returns s truncated to maxLen with "..." suffix if truncated.
|
||||
func Truncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 || len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// ActionDisplayName returns the action's display name for status messages, or "Tool" if nil.
|
||||
func ActionDisplayName(action types.Action) string {
|
||||
if action == nil {
|
||||
return "Tool"
|
||||
}
|
||||
return action.Definition().Name.String()
|
||||
}
|
||||
@@ -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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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 *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Message.Content)
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(discord)", "message", ccm.Content)
|
||||
|
||||
// Send the message to the default channel
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Message.Content)
|
||||
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.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.Message.Content,
|
||||
Content: ccm.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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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 *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Message.Content)
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(email)", "message", ccm.Content)
|
||||
|
||||
// Send the message to the default email
|
||||
e.sendMail(
|
||||
e.defaultEmail,
|
||||
"Message from LocalAGI",
|
||||
ccm.Message.Content,
|
||||
ccm.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.Message.Content,
|
||||
Content: ccm.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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/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 *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Message.Content)
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(irc)", "message", ccm.Content)
|
||||
|
||||
// Split the response into multiple messages if it's too long
|
||||
maxLength := 400 // Safe limit for most IRC servers
|
||||
response := ccm.Message.Content
|
||||
response := ccm.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.Message.Content,
|
||||
Content: ccm.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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
@@ -93,6 +93,25 @@ func (m *Matrix) AgentReasoningCallback() func(state types.ActionCurrentState) b
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForRoom cancels any active job for the given room
|
||||
func (m *Matrix) cancelActiveJobForRoom(roomID string) {
|
||||
m.activeJobsMutex.RLock()
|
||||
ctxs, exists := m.activeJobs[roomID]
|
||||
m.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info(fmt.Sprintf("Cancelling active job for room: %s", roomID))
|
||||
|
||||
// Mark the job as inactive
|
||||
m.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(m.activeJobs, roomID)
|
||||
m.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
if m.roomID != evt.RoomID.String() && m.roomMode { // If we have a roomID and it's not the same as the event room
|
||||
// Skip messages from other rooms
|
||||
@@ -117,6 +136,9 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this room before starting a new one
|
||||
m.cancelActiveJobForRoom(evt.RoomID.String())
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("matrix:%s", evt.RoomID.String()))
|
||||
|
||||
message := evt.Content.AsMessage().Body
|
||||
@@ -137,10 +159,9 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add room and conversation_id for tracking and cancel-previous-on-new-message
|
||||
// Add room to metadata for tracking
|
||||
metadata := map[string]any{
|
||||
"room": evt.RoomID.String(),
|
||||
types.MetadataKeyConversationID: "matrix:" + evt.RoomID.String(),
|
||||
"room": evt.RoomID.String(),
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
@@ -204,16 +225,16 @@ func (m *Matrix) Start(a *agent.Agent) {
|
||||
|
||||
if m.roomID != "" {
|
||||
// handle new conversations
|
||||
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)
|
||||
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)
|
||||
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.Message.Content,
|
||||
Content: ccm.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+133
-314
@@ -4,23 +4,20 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"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"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/services/connectors/common"
|
||||
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
|
||||
@@ -39,7 +36,6 @@ type Slack struct {
|
||||
// To track placeholder messages
|
||||
placeholders map[string]string // map[jobUUID]messageTS
|
||||
placeholderMutex sync.RWMutex
|
||||
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
|
||||
apiClient *slack.Client
|
||||
|
||||
// Track active jobs for cancellation
|
||||
@@ -57,39 +53,15 @@ func NewSlack(config map[string]string) *Slack {
|
||||
channelID: config["channelID"],
|
||||
channelMode: config["channelMode"] == "true",
|
||||
placeholders: make(map[string]string),
|
||||
jobStatus: make(map[string]*common.StatusAccumulator),
|
||||
activeJobs: make(map[string][]*types.Job),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Slack) AgentResultCallback() func(state types.ActionState) {
|
||||
return func(state types.ActionState) {
|
||||
// Update placeholder with tool result if still in progress
|
||||
job := state.ActionCurrentState.Job
|
||||
if job != nil && job.Metadata != nil {
|
||||
if channel, ok := job.Metadata["channel"].(string); ok && channel != "" {
|
||||
t.placeholderMutex.Lock()
|
||||
msgTs, exists := t.placeholders[job.UUID]
|
||||
if exists && msgTs != "" && t.apiClient != nil {
|
||||
acc, ok := t.jobStatus[job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[job.UUID] = acc
|
||||
}
|
||||
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
|
||||
thought := acc.BuildMessage(thinkingMessage, 3000)
|
||||
t.placeholderMutex.Unlock()
|
||||
_, _, _, err := t.apiClient.UpdateMessage(
|
||||
channel,
|
||||
msgTs,
|
||||
slack.MsgOptionText(githubmarkdownconvertergo.Slack(thought), false),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating tool result message: %v", err))
|
||||
}
|
||||
t.placeholderMutex.Lock()
|
||||
}
|
||||
t.placeholderMutex.Unlock()
|
||||
// Mark the job as completed when we get the final result
|
||||
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
|
||||
if channel, ok := state.ActionCurrentState.Job.Metadata["channel"].(string); ok && channel != "" {
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, channel)
|
||||
t.activeJobsMutex.Unlock()
|
||||
@@ -101,7 +73,7 @@ func (t *Slack) AgentResultCallback() func(state types.ActionState) {
|
||||
func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
|
||||
return func(state types.ActionCurrentState) bool {
|
||||
// Check if we have a placeholder message for this job
|
||||
t.placeholderMutex.Lock()
|
||||
t.placeholderMutex.RLock()
|
||||
msgTs, exists := t.placeholders[state.Job.UUID]
|
||||
channel := ""
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -109,31 +81,18 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
|
||||
channel = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgTs == "" || channel == "" || t.apiClient == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
return true // Skip if we don't have a message to update
|
||||
}
|
||||
|
||||
// Update when we have reasoning or a tool call to show
|
||||
if state.Reasoning == "" && state.Action == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
acc, ok := t.jobStatus[state.Job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[state.Job.UUID] = acc
|
||||
}
|
||||
thought := thinkingMessage + "\n\n"
|
||||
if state.Reasoning != "" {
|
||||
acc.AppendReasoning(state.Reasoning)
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
}
|
||||
if state.Action != nil {
|
||||
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
|
||||
}
|
||||
thought := acc.BuildMessage(thinkingMessage, 3000)
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Update the placeholder message with the current reasoning
|
||||
_, _, _, err := t.apiClient.UpdateMessage(
|
||||
channel,
|
||||
msgTs,
|
||||
@@ -146,6 +105,25 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForChannel cancels any active job for the given channel
|
||||
func (t *Slack) cancelActiveJobForChannel(channelID string) {
|
||||
t.activeJobsMutex.RLock()
|
||||
ctxs, exists := t.activeJobs[channelID]
|
||||
t.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info(fmt.Sprintf("Cancelling active job for channel: %s", channelID))
|
||||
|
||||
// Mark the job as inactive
|
||||
t.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(t.activeJobs, channelID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func cleanUpUsernameFromMessage(message string, b *slack.AuthTestResponse) string {
|
||||
cleaned := strings.ReplaceAll(message, "<@"+b.UserID+">", "")
|
||||
cleaned = strings.ReplaceAll(cleaned, "<@"+b.BotID+">", "")
|
||||
@@ -171,193 +149,63 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
||||
return message
|
||||
}
|
||||
|
||||
// attachmentsFromMetadataOnly returns link/image attachments from metadata (no file uploads).
|
||||
// Used when posting a message so we can include URLs/images in the same post.
|
||||
func attachmentsFromMetadataOnly(metadata map[string]interface{}) (attachments []slack.Attachment) {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
if urls, exists := metadata[actions.MetadataUrls]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(urls)) {
|
||||
attachments = append(attachments, slack.Attachment{
|
||||
Title: "URL",
|
||||
TitleLink: url,
|
||||
Text: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
if imagesUrls, exists := metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(imagesUrls)) {
|
||||
attachments = append(attachments, slack.Attachment{
|
||||
Title: "Image",
|
||||
TitleLink: url,
|
||||
ImageURL: url,
|
||||
})
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
// stringSliceFromMetadata converts a metadata value to []string, supporting both
|
||||
// []string and []interface{} (e.g. from JSON). Returns nil if the value is not a supported slice type.
|
||||
func stringSliceFromMetadata(v interface{}) []string {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
return []string{v}
|
||||
case []string:
|
||||
return v
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(v))
|
||||
for _, e := range v {
|
||||
if s, ok := e.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadFilesFromMetadata uploads song and PDF files from metadata to the given thread.
|
||||
// Paths must be local filesystem paths; URLs will be skipped with a clear log.
|
||||
// Call after posting a message so threadTs is the message timestamp.
|
||||
func uploadFilesFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) {
|
||||
if metadata == nil {
|
||||
return
|
||||
}
|
||||
isURL := func(p string) bool {
|
||||
return strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://")
|
||||
}
|
||||
if songPaths, exists := metadata[actions.MetadataSongs]; exists {
|
||||
sl := stringSliceFromMetadata(songPaths)
|
||||
for _, path := range xstrings.UniqueSlice(sl) {
|
||||
if isURL(path) {
|
||||
xlog.Error("Slack upload skipped: song path is a URL, need local path", "path", path)
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading song file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
if filename == "" || filename == "." {
|
||||
filename = "audio"
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
Title: filename,
|
||||
InitialComment: "Generated song",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for song", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pdfPaths, exists := metadata[actions.MetadataPDFs]; exists {
|
||||
sl := stringSliceFromMetadata(pdfPaths)
|
||||
for _, path := range xstrings.UniqueSlice(sl) {
|
||||
if isURL(path) {
|
||||
xlog.Error("Slack upload skipped: PDF path is a URL, need local path", "path", path)
|
||||
continue
|
||||
}
|
||||
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("Uploading PDF from metadata to Slack thread", "filename", filename, "path", path)
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: filename,
|
||||
Title: filename,
|
||||
InitialComment: "Generated PDF document",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for PDF", "error", err, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle generated images (download from URL and upload as file, so temporary URLs like DALL-E are preserved)
|
||||
if imageUrls, exists := metadata[actions.MetadataImages]; exists {
|
||||
sl := stringSliceFromMetadata(imageUrls)
|
||||
for _, imgURL := range xstrings.UniqueSlice(sl) {
|
||||
resp, err := http.Get(imgURL)
|
||||
if err != nil {
|
||||
xlog.Error("Error downloading image for Slack upload", "url", imgURL, "error", err)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
xlog.Error("Error reading image body for Slack upload", "url", imgURL, "error", err)
|
||||
continue
|
||||
}
|
||||
if len(data) == 0 {
|
||||
xlog.Error("Empty image body for Slack upload", "url", imgURL)
|
||||
continue
|
||||
}
|
||||
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
|
||||
Reader: bytes.NewReader(data),
|
||||
FileSize: len(data),
|
||||
ThreadTimestamp: threadTs,
|
||||
Channel: channelID,
|
||||
Filename: "image.png",
|
||||
Title: "Generated image",
|
||||
InitialComment: "Generated image",
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Slack UploadFileV2 failed for image", "error", err, "url", imgURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attachmentsAndUploadsFromMetadata returns link/image attachments and uploads files (songs, PDFs)
|
||||
// from a metadata map. Used both by JobResult.State and by ConversationMessage.Metadata
|
||||
// (e.g. when newconversation/send_message is used so metadata is passed without going through State).
|
||||
func attachmentsAndUploadsFromMetadata(metadata map[string]interface{}, api *slack.Client, channelID, threadTs string) (attachments []slack.Attachment) {
|
||||
attachments = attachmentsFromMetadataOnly(metadata)
|
||||
uploadFilesFromMetadata(metadata, api, channelID, threadTs)
|
||||
return attachments
|
||||
}
|
||||
|
||||
// attachmentsFromJobResponseOnly returns link/image attachments from job response without uploading files.
|
||||
// Use with uploadJobResultFiles so uploads happen once per reply.
|
||||
func attachmentsFromJobResponseOnly(j *types.JobResult) []slack.Attachment {
|
||||
if j == nil {
|
||||
return nil
|
||||
}
|
||||
var out []slack.Attachment
|
||||
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
|
||||
for _, state := range j.State {
|
||||
out = append(out, attachmentsFromMetadataOnly(state.Metadata)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// uploadJobResultFiles uploads all song/PDF files from job result states to the given thread once.
|
||||
func uploadJobResultFiles(res *types.JobResult, api *slack.Client, channelID, threadTs string) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
for _, state := range res.State {
|
||||
uploadFilesFromMetadata(state.Metadata, api, channelID, threadTs)
|
||||
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 {
|
||||
for _, url := range xstrings.UniqueSlice(urls.([]string)) {
|
||||
attachment := slack.Attachment{
|
||||
Title: "URL",
|
||||
TitleLink: url,
|
||||
Text: url,
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
}
|
||||
|
||||
// coming from the gen image actions
|
||||
if imagesUrls, exists := state.Metadata[actions.MetadataImages]; exists {
|
||||
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
|
||||
attachment := slack.Attachment{
|
||||
Title: "Image",
|
||||
TitleLink: url,
|
||||
ImageURL: url,
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ImageData represents a single image with its metadata
|
||||
@@ -532,6 +380,9 @@ func (t *Slack) handleChannelMessage(
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this channel before starting a new one
|
||||
t.cancelActiveJobForChannel(ev.Channel)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID))
|
||||
|
||||
message := replaceUserIDsWithNamesInMessage(api, cleanUpUsernameFromMessage(ev.Text, b))
|
||||
@@ -560,10 +411,9 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add channel and conversation_id for tracking and cancel-previous-on-new-message
|
||||
// Add channel to metadata for tracking
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
@@ -611,61 +461,50 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
xlog.Debug("After adding message to conversation tracker", "conversation", a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID)))
|
||||
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
replyWithPostMessage(convertedResponse, api, ev, postMessageParams, res)
|
||||
//res.Response = githubmarkdownconvertergo.Slack(res.Response)
|
||||
|
||||
replyWithPostMessage(res.Response, api, ev, postMessageParams, res)
|
||||
|
||||
}()
|
||||
}
|
||||
|
||||
func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackevents.MessageEvent, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
|
||||
attachments := attachmentsFromJobResponseOnly(res)
|
||||
if len(finalResponse) > 4000 {
|
||||
// Split response into multiple messages; post first to get thread ts, upload files once, then post rest in thread
|
||||
// split response in multiple messages, and update the first
|
||||
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
var firstTs string
|
||||
for i, message := range messages {
|
||||
opts := []slack.MsgOption{
|
||||
|
||||
for _, message := range messages {
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
}
|
||||
if i > 0 && firstTs != "" {
|
||||
opts = append(opts, slack.MsgOptionTS(firstTs))
|
||||
}
|
||||
_, ts, err := api.PostMessage(ev.Channel, opts...)
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
firstTs = ts
|
||||
uploadJobResultFiles(res, api, ev.Channel, firstTs)
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, ts, err := api.PostMessage(ev.Channel,
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionText(res.Response, true),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
// slack.MsgOptionTS(ts),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
return
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
}
|
||||
uploadJobResultFiles(res, api, ev.Channel, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, postMessageParams slack.PostMessageParameters, res *types.JobResult) {
|
||||
attachments := attachmentsFromJobResponseOnly(res)
|
||||
// Use the thread root timestamp (ts), not the placeholder reply timestamp (msgTs).
|
||||
// Slack API: "Never use a reply's ts value; use its parent instead."
|
||||
uploadJobResultFiles(res, api, ev.Channel, ts)
|
||||
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, res *types.JobResult) {
|
||||
if len(finalResponse) > 3000 {
|
||||
// split response in multiple messages, and update the first
|
||||
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
|
||||
_, _, _, err := api.UpdateMessage(
|
||||
@@ -673,12 +512,11 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(messages[0], false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
slack.MsgOptionText(messages[0], true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
}
|
||||
|
||||
for i, message := range messages {
|
||||
@@ -688,12 +526,11 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
_, _, err = api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionTS(ts),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -702,12 +539,11 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
slack.MsgOptionText(finalResponse, true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,10 +675,9 @@ func (t *Slack) handleMention(
|
||||
}
|
||||
}
|
||||
|
||||
// Add channel and conversation_id for callbacks and cancel-previous-on-new-message
|
||||
// Add channel to job metadata for use in callbacks
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
}
|
||||
|
||||
// Call the agent with the conversation history
|
||||
@@ -852,9 +687,9 @@ func (t *Slack) handleMention(
|
||||
types.WithMetadata(metadata),
|
||||
)
|
||||
|
||||
if res == nil || res.Response == "" {
|
||||
if res.Response == "" {
|
||||
xlog.Debug(fmt.Sprintf("Empty response from agent"))
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, postMessageParams, res)
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, res)
|
||||
|
||||
// _, _, err := api.DeleteMessage(ev.Channel, msgTs)
|
||||
// if err != nil {
|
||||
@@ -865,24 +700,20 @@ func (t *Slack) handleMention(
|
||||
|
||||
// get user id
|
||||
user, err := api.GetUserInfo(ev.User)
|
||||
displayName := ev.User
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error getting user info: %v", err))
|
||||
} else if user != nil {
|
||||
displayName = user.Name
|
||||
}
|
||||
|
||||
// Format the final response (convert GitHub markdown to Slack mrkdwn)
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", displayName, convertedResponse)
|
||||
// Format the final response
|
||||
//finalResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", user.Name, res.Response)
|
||||
xlog.Debug("Send final response to slack", "response", finalResponse)
|
||||
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, postMessageParams, res)
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, res)
|
||||
|
||||
// Clean up the placeholder map and job status
|
||||
// Clean up the placeholder map
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
}
|
||||
@@ -903,34 +734,22 @@ 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 (e.g. send_message / newconversation action)
|
||||
// Preserve metadata (PDFs, songs, images, URLs) so attachments are not lost
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
|
||||
convertedContent := githubmarkdownconvertergo.Slack(ccm.Message.Content)
|
||||
attachments := attachmentsFromMetadataOnly(ccm.Metadata)
|
||||
channelID, ts, err := api.PostMessage(t.channelID,
|
||||
// handle new conversations
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Content)
|
||||
_, _, err := api.PostMessage(t.channelID,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(convertedContent, false),
|
||||
slack.MsgOptionText(ccm.Content, true),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
}
|
||||
// Always upload files (PDFs, songs) when metadata is present—to the same thread if post succeeded, else to channel
|
||||
if ccm.Metadata != nil {
|
||||
ch, threadTs := t.channelID, ""
|
||||
if err == nil {
|
||||
ch, threadTs = channelID, ts
|
||||
}
|
||||
uploadFilesFromMetadata(ccm.Metadata, api, ch, threadTs)
|
||||
}
|
||||
a.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("slack:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Message.Content,
|
||||
Content: ccm.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
|
||||
+74
-216
@@ -10,9 +10,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -22,10 +20,10 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/services/connectors/common"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -42,7 +40,6 @@ type Telegram struct {
|
||||
// To track placeholder messages
|
||||
placeholders map[string]int // map[jobUUID]messageID
|
||||
placeholderMutex sync.RWMutex
|
||||
jobStatus map[string]*common.StatusAccumulator // map[jobUUID]accumulator
|
||||
|
||||
// Track active jobs for cancellation
|
||||
activeJobs map[int64][]*types.Job // map[chatID]bool to track if a chat has active processing
|
||||
@@ -234,6 +231,9 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this chat before starting a new one
|
||||
t.cancelActiveJobForChat(update.Message.Chat.ID)
|
||||
|
||||
// Clean up the message by removing bot mentions
|
||||
message := strings.ReplaceAll(update.Message.Text, "@"+botInfo.Username, "")
|
||||
update.Message.Text = strings.TrimSpace(message)
|
||||
@@ -259,10 +259,9 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
// Add chat ID to metadata for tracking
|
||||
metadata := map[string]interface{}{
|
||||
"chatID": update.Message.Chat.ID,
|
||||
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", update.Message.Chat.ID),
|
||||
}
|
||||
|
||||
// Track if the original message was audio for TTS response
|
||||
@@ -306,10 +305,9 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map and job status
|
||||
// Clean up the placeholder map
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -424,48 +422,21 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
|
||||
func (t *Telegram) AgentResultCallback() func(state types.ActionState) {
|
||||
return func(state types.ActionState) {
|
||||
job := state.ActionCurrentState.Job
|
||||
if job == nil || job.Metadata == nil {
|
||||
return
|
||||
}
|
||||
chatID, ok := job.Metadata["chatID"].(int64)
|
||||
if !ok || chatID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Update placeholder with tool result if still in progress
|
||||
t.placeholderMutex.Lock()
|
||||
msgID, exists := t.placeholders[job.UUID]
|
||||
if exists && msgID != 0 && t.bot != nil {
|
||||
acc, ok := t.jobStatus[job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[job.UUID] = acc
|
||||
// Mark the job as completed when we get the final result
|
||||
if state.ActionCurrentState.Job != nil && state.ActionCurrentState.Job.Metadata != nil {
|
||||
if chatID, ok := state.ActionCurrentState.Job.Metadata["chatID"].(int64); ok && chatID != 0 {
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
acc.AppendToolResult(common.ActionDisplayName(state.Action), state.Result)
|
||||
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
|
||||
t.placeholderMutex.Unlock()
|
||||
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
Text: thought,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error updating tool result message", "error", err)
|
||||
}
|
||||
t.placeholderMutex.Lock()
|
||||
}
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
|
||||
return func(state types.ActionCurrentState) bool {
|
||||
t.placeholderMutex.Lock()
|
||||
// Check if we have a placeholder message for this job
|
||||
t.placeholderMutex.RLock()
|
||||
msgID, exists := t.placeholders[state.Job.UUID]
|
||||
chatID := int64(0)
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -473,30 +444,18 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
|
||||
chatID = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgID == 0 || chatID == 0 || t.bot == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
return true // Skip if we don't have a message to update
|
||||
}
|
||||
|
||||
if state.Reasoning == "" && state.Action == nil {
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
acc, ok := t.jobStatus[state.Job.UUID]
|
||||
if !ok {
|
||||
acc = common.NewStatusAccumulator()
|
||||
t.jobStatus[state.Job.UUID] = acc
|
||||
}
|
||||
thought := telegramThinkingMessage + "\n\n"
|
||||
if state.Reasoning != "" {
|
||||
acc.AppendReasoning(state.Reasoning)
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
}
|
||||
if state.Action != nil {
|
||||
acc.AppendToolCall(common.ActionDisplayName(state.Action), state.Params.String())
|
||||
}
|
||||
thought := acc.BuildMessage(telegramThinkingMessage, telegramMaxMessageLength)
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Update the placeholder message with the current reasoning
|
||||
_, err := t.bot.EditMessageText(t.agent.Context(), &bot.EditMessageTextParams{
|
||||
ChatID: chatID,
|
||||
MessageID: msgID,
|
||||
@@ -509,6 +468,25 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelActiveJobForChat cancels any active job for the given chat
|
||||
func (t *Telegram) cancelActiveJobForChat(chatID int64) {
|
||||
t.activeJobsMutex.RLock()
|
||||
ctxs, exists := t.activeJobs[chatID]
|
||||
t.activeJobsMutex.RUnlock()
|
||||
|
||||
if exists {
|
||||
xlog.Info("Cancelling active job for chat", "chatID", chatID)
|
||||
|
||||
// Mark the job as inactive
|
||||
t.activeJobsMutex.Lock()
|
||||
for _, c := range ctxs {
|
||||
c.Cancel()
|
||||
}
|
||||
delete(t.activeJobs, chatID)
|
||||
t.activeJobsMutex.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// sendImageToTelegram downloads and sends an image to Telegram
|
||||
func sendImageToTelegram(ctx context.Context, b *bot.Bot, chatID int64, url string) error {
|
||||
resp, err := http.Get(url)
|
||||
@@ -557,30 +535,6 @@ func sendAudioToTelegram(ctx context.Context, b *bot.Bot, chatID int64, audioDat
|
||||
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
|
||||
@@ -601,72 +555,33 @@ func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, re
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// }
|
||||
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)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 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
|
||||
@@ -723,6 +638,9 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel any active job for this chat before starting a new one
|
||||
t.cancelActiveJobForChat(update.Message.Chat.ID)
|
||||
|
||||
currentConv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("telegram:%d", update.Message.From.ID))
|
||||
|
||||
message, err := t.chatFromMessage(update)
|
||||
@@ -756,10 +674,9 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
// Add chat ID to metadata for tracking
|
||||
metadata := map[string]interface{}{
|
||||
"chatID": update.Message.Chat.ID,
|
||||
types.MetadataKeyConversationID: fmt.Sprintf("telegram:%d", update.Message.Chat.ID),
|
||||
}
|
||||
|
||||
// Track if the original message was audio for TTS response
|
||||
@@ -791,10 +708,9 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map and job status
|
||||
// Clean up the placeholder map
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -939,68 +855,11 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
|
||||
if t.channelID != "" {
|
||||
// handle new conversations
|
||||
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
|
||||
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
|
||||
xlog.Debug("Subscriber(telegram)", "message", ccm.Content)
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: t.channelID,
|
||||
Text: ccm.Message.Content,
|
||||
Text: ccm.Content,
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error sending message", "error", err)
|
||||
@@ -1010,7 +869,7 @@ func (t *Telegram) Start(a *agent.Agent) {
|
||||
t.agent.SharedState().ConversationTracker.AddMessage(
|
||||
fmt.Sprintf("telegram:%s", t.channelID),
|
||||
openai.ChatCompletionMessage{
|
||||
Content: ccm.Message.Content,
|
||||
Content: ccm.Content,
|
||||
Role: "assistant",
|
||||
},
|
||||
)
|
||||
@@ -1036,7 +895,6 @@ func NewTelegramConnector(config map[string]string) (*Telegram, error) {
|
||||
Token: token,
|
||||
admins: admins,
|
||||
placeholders: make(map[string]int),
|
||||
jobStatus: make(map[string]*common.StatusAccumulator),
|
||||
activeJobs: make(map[int64][]*types.Job),
|
||||
channelID: config["channel_id"],
|
||||
groupMode: config["group_mode"] == "true",
|
||||
|
||||
@@ -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)
|
||||
|
||||
memoryIdxPath := memoryIndexPath(a.Name, dynamicConfig)
|
||||
memoryFilePath := memoryPath(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(memoryIdxPath, dynamicConfig)
|
||||
_, memory, _ := actions.NewMemoryActions(memoryFilePath, 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/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/traefik/yaegi/interp"
|
||||
"github.com/traefik/yaegi/stdlib"
|
||||
)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
|
||||
skilldomain "github.com/mudler/skillserver/pkg/domain"
|
||||
)
|
||||
|
||||
const defaultSkillsIntro = "You can use the following skills to help with the task.\nTo request the skill, you need to use the `request_skill` tool. The skill name is the name of the skill you want to use.\n"
|
||||
|
||||
// defaultSkillsTemplate is the default template that mimics the current XML behavior
|
||||
const defaultSkillsTemplate = defaultSkillsIntro + `<available_skills>
|
||||
{{range .Skills}}
|
||||
<skill>
|
||||
<name>{{escapeXML .Name}}</name>
|
||||
<description>{{escapeXML .Description}}</description>
|
||||
</skill>
|
||||
{{end}}
|
||||
</available_skills>`
|
||||
|
||||
// Skill is a local representation of a skill for template rendering
|
||||
type Skill struct {
|
||||
Name string
|
||||
Description string
|
||||
ID string
|
||||
}
|
||||
|
||||
// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block
|
||||
type skillsPrompt struct {
|
||||
listSkills func() ([]skilldomain.Skill, error)
|
||||
customTemplate string
|
||||
}
|
||||
|
||||
// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills.
|
||||
// If customTemplate is non-empty, it is used as a template with {{.Skills}} slice.
|
||||
// Otherwise, the default template is used (mimics current XML behavior).
|
||||
func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customTemplate string) agent.DynamicPrompt {
|
||||
return &skillsPrompt{listSkills: listSkills, customTemplate: customTemplate}
|
||||
}
|
||||
|
||||
func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) {
|
||||
skills, err := p.listSkills()
|
||||
if err != nil {
|
||||
return types.PromptResult{}, err
|
||||
}
|
||||
|
||||
// Convert skilldomain.Skill to local Skill type for template rendering
|
||||
localSkills := make([]Skill, len(skills))
|
||||
for i, s := range skills {
|
||||
desc := ""
|
||||
if s.Metadata != nil && s.Metadata.Description != "" {
|
||||
desc = s.Metadata.Description
|
||||
}
|
||||
localSkills[i] = Skill{
|
||||
Name: s.ID,
|
||||
Description: desc,
|
||||
ID: s.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom template or default
|
||||
templ := p.customTemplate
|
||||
if templ == "" {
|
||||
templ = defaultSkillsTemplate
|
||||
}
|
||||
|
||||
// Parse and execute the template
|
||||
tmpl, err := template.New("skillsPrompt").Funcs(template.FuncMap{
|
||||
"escapeXML": escapeXML,
|
||||
}).Funcs(sprig.FuncMap()).Parse(templ)
|
||||
if err != nil {
|
||||
return types.PromptResult{}, fmt.Errorf("failed to parse skills template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.Execute(&buf, struct {
|
||||
Skills []Skill
|
||||
}{
|
||||
Skills: localSkills,
|
||||
})
|
||||
if err != nil {
|
||||
return types.PromptResult{}, fmt.Errorf("failed to execute skills template: %w", err)
|
||||
}
|
||||
|
||||
return types.PromptResult{Content: buf.String()}, nil
|
||||
}
|
||||
|
||||
func (p *skillsPrompt) Role() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/xlog"
|
||||
|
||||
skilldomain "github.com/mudler/skillserver/pkg/domain"
|
||||
skillgit "github.com/mudler/skillserver/pkg/git"
|
||||
skillmcp "github.com/mudler/skillserver/pkg/mcp"
|
||||
)
|
||||
|
||||
// SkillsDirName is the subdirectory under state dir where skills are stored
|
||||
const SkillsDirName = "skills"
|
||||
|
||||
// Service manages the skills directory (fixed at stateDir/skills), lazy SkillManager, dynamic prompt, and in-process MCP session
|
||||
type Service struct {
|
||||
stateDir string
|
||||
mu sync.Mutex
|
||||
createMu sync.Mutex // serializes manager creation so only one createManager() runs at a time
|
||||
manager skilldomain.SkillManager
|
||||
mcpSrv *skillmcp.Server
|
||||
session *mcp.ClientSession
|
||||
}
|
||||
|
||||
// NewService creates a skills service. Skills are stored under stateDir/skills.
|
||||
func NewService(stateDir string) (*Service, error) {
|
||||
return &Service{
|
||||
stateDir: stateDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSkillsDir returns the skills directory path (always stateDir/skills)
|
||||
func (s *Service) GetSkillsDir() string {
|
||||
return filepath.Join(s.stateDir, SkillsDirName)
|
||||
}
|
||||
|
||||
// RefreshManagerFromConfig updates the existing manager's git repo list and rebuilds the index
|
||||
// (same as skillserver: UpdateGitRepos + RebuildIndex in place). Does nothing if no manager exists yet.
|
||||
// Call this when git repo config changes instead of invalidating; avoids blocking ListSkills on full recreate.
|
||||
func (s *Service) RefreshManagerFromConfig() {
|
||||
skillsDir := s.GetSkillsDir()
|
||||
cm := skillgit.NewConfigManager(skillsDir)
|
||||
repos, err := cm.LoadConfig()
|
||||
if err != nil {
|
||||
xlog.Warn("[skills] RefreshManagerFromConfig: could not load config", "error", err)
|
||||
return
|
||||
}
|
||||
gitRepoNames := make([]string, 0, len(repos))
|
||||
for _, r := range repos {
|
||||
if r.Enabled && r.Name != "" {
|
||||
gitRepoNames = append(gitRepoNames, r.Name)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
mgr := s.manager
|
||||
s.mu.Unlock()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
if fm, ok := mgr.(*skilldomain.FileSystemManager); ok {
|
||||
fm.UpdateGitRepos(gitRepoNames)
|
||||
if err := mgr.RebuildIndex(); err != nil {
|
||||
xlog.Warn("[skills] RefreshManagerFromConfig: RebuildIndex failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createManager builds a new SkillManager (reads config and calls NewFileSystemManager).
|
||||
// Must be called without holding s.mu because NewFileSystemManager runs RebuildIndex() which is slow.
|
||||
func (s *Service) createManager() (skilldomain.SkillManager, error) {
|
||||
skillsDir := s.GetSkillsDir()
|
||||
gitRepos := []string{}
|
||||
cm := skillgit.NewConfigManager(skillsDir)
|
||||
repos, err := cm.LoadConfig()
|
||||
if err != nil {
|
||||
xlog.Warn("Could not load git-repos config for skills", "error", err)
|
||||
} else {
|
||||
for _, r := range repos {
|
||||
if r.Enabled && r.Name != "" {
|
||||
gitRepos = append(gitRepos, r.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
mgr, err := skilldomain.NewFileSystemManager(skillsDir, gitRepos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
// GetManager returns the SkillManager if the skills dir is set, otherwise nil.
|
||||
// Manager creation is serialized (createMu) so only one createManager() runs at a time,
|
||||
// avoiding concurrent RebuildIndex and filesystem contention.
|
||||
func (s *Service) GetManager() (skilldomain.SkillManager, error) {
|
||||
s.mu.Lock()
|
||||
if s.manager != nil {
|
||||
mgr := s.manager
|
||||
s.mu.Unlock()
|
||||
return mgr, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.createMu.Lock()
|
||||
defer s.createMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
if s.manager != nil {
|
||||
mgr := s.manager
|
||||
s.mu.Unlock()
|
||||
return mgr, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
mgr, err := s.createManager()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.manager = mgr
|
||||
s.mu.Unlock()
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
// GetSkillsPrompt returns a DynamicPrompt that injects the available skills XML (or nil if no manager).
|
||||
// When config is non-nil and config.SkillsPrompt is set, that text is used as the intro; otherwise the default intro is used.
|
||||
func (s *Service) GetSkillsPrompt(config *state.AgentConfig) (agent.DynamicPrompt, error) {
|
||||
mgr, err := s.GetManager()
|
||||
if err != nil || mgr == nil {
|
||||
return nil, err
|
||||
}
|
||||
customTemplate := ""
|
||||
if config != nil && config.SkillsPrompt != "" {
|
||||
customTemplate = config.SkillsPrompt
|
||||
}
|
||||
return NewSkillsPrompt(mgr.ListSkills, customTemplate), nil
|
||||
}
|
||||
|
||||
// GetMCPSession returns a shared MCP client session connected to the in-process skillserver (starts on first use)
|
||||
func (s *Service) GetMCPSession(ctx context.Context) (*mcp.ClientSession, error) {
|
||||
s.mu.Lock()
|
||||
if s.session != nil {
|
||||
sess := s.session
|
||||
s.mu.Unlock()
|
||||
return sess, nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
mgr, err := s.GetManager()
|
||||
if err != nil || mgr == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.session != nil {
|
||||
sess := s.session
|
||||
s.mu.Unlock()
|
||||
return sess, nil
|
||||
}
|
||||
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||
s.mcpSrv = skillmcp.NewServer(mgr)
|
||||
go func() {
|
||||
if err := s.mcpSrv.RunWithTransport(ctx, serverTransport); err != nil && ctx.Err() == nil {
|
||||
xlog.Error("Skills MCP server exited", "error", err)
|
||||
}
|
||||
}()
|
||||
client := mcp.NewClient(&mcp.Implementation{Name: "LocalAGI", Version: "v1.0.0"}, nil)
|
||||
session, err := client.Connect(ctx, clientTransport, nil)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.session = session
|
||||
s.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
localagi "github.com/mudler/LocalAGI/pkg/client"
|
||||
"github.com/mudler/LocalAGI/pkg/utils/ptr"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/mudler/LocalAGI/pkg/xlog"
|
||||
"github.com/sashabaranov/go-openai/jsonschema"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
|
||||
+6
-50
@@ -2,7 +2,6 @@ package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -16,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"
|
||||
@@ -27,42 +26,22 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
|
||||
fiber "github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/filesystem"
|
||||
"github.com/gofiber/template/html/v2"
|
||||
)
|
||||
|
||||
type (
|
||||
App struct {
|
||||
config *Config
|
||||
config *Config
|
||||
*fiber.App
|
||||
sharedState *internalTypes.AgentSharedState
|
||||
collectionsState *CollectionsState // set when RegisterCollectionRoutes runs; used for in-process RAG
|
||||
sharedState *internalTypes.AgentSharedState
|
||||
}
|
||||
)
|
||||
|
||||
//go:embed public/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
func NewApp(opts ...Option) *App {
|
||||
config := NewConfig(opts...)
|
||||
|
||||
// Initialize a new Fiber app
|
||||
// Pass the engine to the Views
|
||||
|
||||
// Create the engine using your embedded files
|
||||
engine := html.NewFileSystem(http.FS(staticFiles), ".html")
|
||||
|
||||
// Pass the engine to Fiber when creating the app
|
||||
webapp := fiber.New(fiber.Config{
|
||||
Views: engine,
|
||||
})
|
||||
|
||||
webapp.Use("/public", filesystem.New(filesystem.Config{
|
||||
Root: http.FS(staticFiles),
|
||||
// PathPrefix tells the middleware to look inside the embedded "public" folder
|
||||
PathPrefix: "public",
|
||||
Browse: false, // Set to true if you want directory browsing
|
||||
}))
|
||||
webapp := fiber.New(fiber.Config{})
|
||||
|
||||
a := &App{
|
||||
config: config,
|
||||
@@ -386,20 +365,7 @@ func (a *App) Chat(pool *state.AgentPool) func(c *fiber.Ctx) error {
|
||||
// Ask the agent for a response
|
||||
response := agent.Ask(coreTypes.WithText(message))
|
||||
|
||||
if response == nil {
|
||||
// Ask returned nil (e.g. context cancelled or WaitResult failed)
|
||||
xlog.Error("Agent returned nil response", "agent", agentName)
|
||||
errorData, err := json.Marshal(map[string]interface{}{
|
||||
"error": "agent request failed or was cancelled",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
xlog.Error("Error marshaling error message", "error", err)
|
||||
} else {
|
||||
manager.Send(
|
||||
sse.NewMessage(string(errorData)).WithEvent("json_error"))
|
||||
}
|
||||
} else if response.Error != nil {
|
||||
if response.Error != nil {
|
||||
// Send error message
|
||||
xlog.Error("Error asking agent", "agent", agentName, "error", response.Error)
|
||||
errorData, err := json.Marshal(map[string]interface{}{
|
||||
@@ -589,17 +555,7 @@ func (a *App) Responses(pool *state.AgentPool, tracker *conversations.Conversati
|
||||
}
|
||||
|
||||
agentName := request.Model
|
||||
newMessages := request.ToChatCompletionMessages()
|
||||
messages := append(conv, newMessages...)
|
||||
|
||||
// Continuing a thread (previous_response_id) without any new user/tool message causes
|
||||
// the job to end with an assistant message, which backends with enable_thinking reject.
|
||||
// Require at least one new message when continuing so we never send assistant-final conv.
|
||||
if previousResponseID != "" && len(conv) > 0 && len(newMessages) == 0 {
|
||||
return c.Status(http.StatusBadRequest).JSON(types.ResponseBody{
|
||||
Error: "previous_response_id was set but no new input was sent; send at least one user or tool message when continuing a conversation",
|
||||
})
|
||||
}
|
||||
messages := append(conv, request.ToChatCompletionMessages()...)
|
||||
|
||||
agent := pool.GetAgent(agentName)
|
||||
if agent == nil {
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/localrecall/rag"
|
||||
"github.com/mudler/localrecall/rag/sources"
|
||||
"github.com/mudler/xlog"
|
||||
"github.com/sashabaranov/go-openai"
|
||||
)
|
||||
|
||||
// newVectorEngine creates the underlying RAG store for a collection.
|
||||
// Returns nil + logs on failure (engine misconfiguration or transient embedding/DB
|
||||
// outage). Callers must check for nil — RAG init failures must not crash the
|
||||
// embedding application; LocalRecall used to os.Exit on these errors but now
|
||||
// returns them, and we surface that to callers as a nil collection.
|
||||
func newVectorEngine(
|
||||
vectorEngineType string,
|
||||
llmClient *openai.Client,
|
||||
apiURL, apiKey, collectionName, dbPath, fileAssets, embeddingModel, databaseURL string,
|
||||
maxChunkSize, chunkOverlap int,
|
||||
) *rag.PersistentKB {
|
||||
var (
|
||||
kb *rag.PersistentKB
|
||||
err error
|
||||
)
|
||||
switch vectorEngineType {
|
||||
case "chromem":
|
||||
xlog.Info("Chromem collection", "collectionName", collectionName, "dbPath", dbPath)
|
||||
kb, err = rag.NewPersistentChromeCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap)
|
||||
case "localai":
|
||||
xlog.Info("LocalAI collection", "collectionName", collectionName, "apiURL", apiURL)
|
||||
kb, err = rag.NewPersistentLocalAICollection(llmClient, apiURL, apiKey, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap)
|
||||
case "postgres":
|
||||
if databaseURL == "" {
|
||||
xlog.Error("DATABASE_URL is required for PostgreSQL engine")
|
||||
return nil
|
||||
}
|
||||
xlog.Info("PostgreSQL collection", "collectionName", collectionName, "databaseURL", databaseURL)
|
||||
kb, err = rag.NewPersistentPostgresCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap, databaseURL)
|
||||
default:
|
||||
xlog.Error("Unknown vector engine", "engine", vectorEngineType)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
xlog.Error("Failed to create vector engine collection",
|
||||
"engine", vectorEngineType, "collection", collectionName, "error", err)
|
||||
return nil
|
||||
}
|
||||
return kb
|
||||
}
|
||||
|
||||
// backendInProcess implements Backend using in-process state.
|
||||
type backendInProcess struct {
|
||||
state *State
|
||||
cfg *Config
|
||||
openAIClient *openai.Client
|
||||
}
|
||||
|
||||
var _ Backend = (*backendInProcess)(nil)
|
||||
|
||||
// lookup returns the cached collection KB for name. If the cache holds a
|
||||
// placeholder (nil entry — the engine init failed at startup, e.g. because
|
||||
// the embedding service was momentarily unreachable when iterating over
|
||||
// existing collections in NewInProcessBackend) it attempts to re-initialise
|
||||
// the engine now so a transient outage doesn't permanently 404 a collection
|
||||
// that still has data on disk / in the vector DB. Returns (nil, false) only
|
||||
// when the collection isn't known at all, or when re-init still fails.
|
||||
func (b *backendInProcess) lookup(name string) (*rag.PersistentKB, bool) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[name]
|
||||
b.state.Mu.RUnlock()
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
if kb != nil {
|
||||
return kb, true
|
||||
}
|
||||
// Placeholder: collection is known on disk but its engine wrapper failed
|
||||
// to construct earlier. Retry under the write lock.
|
||||
b.state.Mu.Lock()
|
||||
defer b.state.Mu.Unlock()
|
||||
if kb, ok := b.state.Collections[name]; ok && kb != nil {
|
||||
return kb, true
|
||||
}
|
||||
kb = newVectorEngine(b.cfg.VectorEngine, b.openAIClient, b.cfg.LLMAPIURL, b.cfg.LLMAPIKey, name, b.cfg.CollectionDBPath, b.cfg.FileAssets, b.cfg.EmbeddingModel, b.cfg.DatabaseURL, b.cfg.MaxChunkingSize, b.cfg.ChunkOverlap)
|
||||
if kb == nil {
|
||||
return nil, false
|
||||
}
|
||||
b.state.Collections[name] = kb
|
||||
b.state.SourceManager.RegisterCollection(name, kb)
|
||||
return kb, true
|
||||
}
|
||||
|
||||
func (b *backendInProcess) ListCollections() ([]string, error) {
|
||||
return rag.ListAllCollections(b.cfg.CollectionDBPath), nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) CreateCollection(name string) error {
|
||||
collection := newVectorEngine(b.cfg.VectorEngine, b.openAIClient, b.cfg.LLMAPIURL, b.cfg.LLMAPIKey, name, b.cfg.CollectionDBPath, b.cfg.FileAssets, b.cfg.EmbeddingModel, b.cfg.DatabaseURL, b.cfg.MaxChunkingSize, b.cfg.ChunkOverlap)
|
||||
if collection == nil {
|
||||
return fmt.Errorf("unsupported or misconfigured vector engine")
|
||||
}
|
||||
b.state.Mu.Lock()
|
||||
b.state.Collections[name] = collection
|
||||
b.state.SourceManager.RegisterCollection(name, collection)
|
||||
b.state.Mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) Upload(collection, filename string, fileBody io.Reader) (string, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
// Write to a temp file; kb.Store will copy it into the correct UUID
|
||||
// subdirectory under the collection's asset dir.
|
||||
tmpDir, err := os.MkdirTemp("", "localagi-upload")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
tmpPath := filepath.Join(tmpDir, filename)
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(out, fileBody); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
out.Close()
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
return kb.Store(tmpPath, map[string]string{"created_at": now})
|
||||
}
|
||||
|
||||
func (b *backendInProcess) ListEntries(collection string) ([]string, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
return kb.ListDocuments(), nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) GetEntryContent(collection, entry string) (string, int, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return "", 0, fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
return kb.GetEntryFileContent(entry)
|
||||
}
|
||||
|
||||
func (b *backendInProcess) Search(collection, query string, maxResults int) ([]SearchResult, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
if maxResults <= 0 {
|
||||
keys := kb.ListDocuments()
|
||||
if len(keys) >= 5 {
|
||||
maxResults = 5
|
||||
} else {
|
||||
maxResults = 1
|
||||
}
|
||||
}
|
||||
results, err := kb.Search(query, maxResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, SearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Metadata: r.Metadata,
|
||||
Similarity: r.Similarity,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) Reset(collection string) error {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
b.state.Mu.Lock()
|
||||
delete(b.state.Collections, collection)
|
||||
b.state.Mu.Unlock()
|
||||
return kb.Reset()
|
||||
}
|
||||
|
||||
func (b *backendInProcess) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
if err := kb.RemoveEntry(entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := kb.ListDocuments()
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) AddSource(collection, url string, intervalMin int) error {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
b.state.SourceManager.RegisterCollection(collection, kb)
|
||||
return b.state.SourceManager.AddSource(collection, url, time.Duration(intervalMin)*time.Minute)
|
||||
}
|
||||
|
||||
func (b *backendInProcess) RemoveSource(collection, url string) error {
|
||||
return b.state.SourceManager.RemoveSource(collection, url)
|
||||
}
|
||||
|
||||
func (b *backendInProcess) ListSources(collection string) ([]SourceInfo, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
srcs := kb.GetExternalSources()
|
||||
out := make([]SourceInfo, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
out = append(out, SourceInfo{
|
||||
URL: s.URL,
|
||||
UpdateInterval: int(s.UpdateInterval.Minutes()),
|
||||
LastUpdate: s.LastUpdate,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *backendInProcess) GetEntryFilePath(collection, entry string) (string, error) {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return "", fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
return kb.GetEntryFilePath(entry)
|
||||
}
|
||||
|
||||
func (b *backendInProcess) EntryExists(collection, entry string) bool {
|
||||
kb, exists := b.lookup(collection)
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return kb.EntryExists(entry)
|
||||
}
|
||||
|
||||
// NewInProcessBackend creates in-process state (load from disk, start sourceManager) and returns
|
||||
// a Backend and the State. The caller can use RAGProviderFromState to create a RAG provider.
|
||||
func NewInProcessBackend(cfg *Config) (Backend, *State) {
|
||||
st := &State{
|
||||
Collections: CollectionList{},
|
||||
SourceManager: rag.NewSourceManager(&sources.Config{}),
|
||||
}
|
||||
|
||||
openaiConfig := openai.DefaultConfig(cfg.LLMAPIKey)
|
||||
openaiConfig.BaseURL = cfg.LLMAPIURL
|
||||
openAIClient := openai.NewClientWithConfig(openaiConfig)
|
||||
|
||||
os.MkdirAll(cfg.CollectionDBPath, 0755)
|
||||
os.MkdirAll(cfg.FileAssets, 0755)
|
||||
|
||||
colls := rag.ListAllCollections(cfg.CollectionDBPath)
|
||||
for _, c := range colls {
|
||||
collection := newVectorEngine(cfg.VectorEngine, openAIClient, cfg.LLMAPIURL, cfg.LLMAPIKey, c, cfg.CollectionDBPath, cfg.FileAssets, cfg.EmbeddingModel, cfg.DatabaseURL, cfg.MaxChunkingSize, cfg.ChunkOverlap)
|
||||
// Register every on-disk collection — even when the engine wrapper
|
||||
// failed to construct (e.g. the embedding service was momentarily
|
||||
// unreachable). A nil entry marks "known on disk but not yet loaded";
|
||||
// backendInProcess.lookup will rehydrate lazily on first access so a
|
||||
// transient outage at boot doesn't permanently 404 collections whose
|
||||
// data is still on disk / in the vector DB.
|
||||
st.Collections[c] = collection
|
||||
if collection != nil {
|
||||
st.SourceManager.RegisterCollection(c, collection)
|
||||
}
|
||||
}
|
||||
|
||||
st.EnsureCollection = func(name string) (*rag.PersistentKB, bool) {
|
||||
st.Mu.Lock()
|
||||
defer st.Mu.Unlock()
|
||||
if kb, ok := st.Collections[name]; ok && kb != nil {
|
||||
return kb, true
|
||||
}
|
||||
collection := newVectorEngine(cfg.VectorEngine, openAIClient, cfg.LLMAPIURL, cfg.LLMAPIKey, name, cfg.CollectionDBPath, cfg.FileAssets, cfg.EmbeddingModel, cfg.DatabaseURL, cfg.MaxChunkingSize, cfg.ChunkOverlap)
|
||||
if collection == nil {
|
||||
return nil, false
|
||||
}
|
||||
st.Collections[name] = collection
|
||||
st.SourceManager.RegisterCollection(name, collection)
|
||||
return collection, true
|
||||
}
|
||||
|
||||
st.SourceManager.Start()
|
||||
|
||||
backend := &backendInProcess{state: st, cfg: cfg, openAIClient: openAIClient}
|
||||
return backend, st
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/localrecall/rag"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// internalRAGAdapter implements agent.RAGDB by calling the in-process *rag.PersistentKB directly.
|
||||
type internalRAGAdapter struct {
|
||||
mu sync.RWMutex
|
||||
collection string
|
||||
kb *rag.PersistentKB
|
||||
}
|
||||
|
||||
var _ agent.RAGDB = (*internalRAGAdapter)(nil)
|
||||
|
||||
func (a *internalRAGAdapter) Store(s string) error {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return fmt.Errorf("collection not available")
|
||||
}
|
||||
t := time.Now()
|
||||
dateTime := t.Format("2006-01-02-15-04-05")
|
||||
hash := md5.Sum([]byte(s))
|
||||
fileName := fmt.Sprintf("%s-%s.txt", dateTime, hex.EncodeToString(hash[:]))
|
||||
tempdir, err := os.MkdirTemp("", "localrag")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tempdir)
|
||||
f := filepath.Join(tempdir, fileName)
|
||||
if err := os.WriteFile(f, []byte(s), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := map[string]string{"created_at": t.Format(time.RFC3339)}
|
||||
_, err = kb.Store(f, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *internalRAGAdapter) Reset() error {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return fmt.Errorf("collection not available")
|
||||
}
|
||||
return kb.Reset()
|
||||
}
|
||||
|
||||
func (a *internalRAGAdapter) Search(s string, similarEntries int) ([]string, error) {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return nil, fmt.Errorf("collection not available")
|
||||
}
|
||||
results, err := kb.Search(s, similarEntries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, fmt.Sprintf("%s (%+v)", r.Content, r.Metadata))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *internalRAGAdapter) Count() int {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return 0
|
||||
}
|
||||
return kb.Count()
|
||||
}
|
||||
|
||||
// internalCompactionAdapter implements state.KBCompactionClient for the same in-process collection.
|
||||
type internalCompactionAdapter struct {
|
||||
mu sync.RWMutex
|
||||
collection string
|
||||
kb *rag.PersistentKB
|
||||
}
|
||||
|
||||
var _ state.KBCompactionClient = (*internalCompactionAdapter)(nil)
|
||||
|
||||
func (a *internalCompactionAdapter) Collection() string {
|
||||
return a.collection
|
||||
}
|
||||
|
||||
func (a *internalCompactionAdapter) ListEntries() ([]string, error) {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return nil, fmt.Errorf("collection not available")
|
||||
}
|
||||
keys := kb.ListDocuments()
|
||||
entries := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
entries[i] = filepath.Base(k)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (a *internalCompactionAdapter) GetEntryContent(entry string) (content string, chunkCount int, err error) {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return "", 0, fmt.Errorf("collection not available")
|
||||
}
|
||||
return kb.GetEntryFileContent(entry)
|
||||
}
|
||||
|
||||
func (a *internalCompactionAdapter) Store(filePath string) error {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return fmt.Errorf("collection not available")
|
||||
}
|
||||
meta := map[string]string{"created_at": time.Now().Format(time.RFC3339)}
|
||||
_, err := kb.Store(filePath, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *internalCompactionAdapter) DeleteEntry(entry string) error {
|
||||
a.mu.RLock()
|
||||
kb := a.kb
|
||||
a.mu.RUnlock()
|
||||
if kb == nil {
|
||||
return fmt.Errorf("collection not available")
|
||||
}
|
||||
return kb.RemoveEntry(entry)
|
||||
}
|
||||
|
||||
// RAGProviderFromState returns a RAG provider function from a State.
|
||||
// External consumers (e.g. LocalAI) can call NewInProcessBackend to get the state,
|
||||
// then pass it here to create a RAG provider for the agent pool.
|
||||
func RAGProviderFromState(cs *State) func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
if cs == nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
name := strings.TrimSpace(strings.ToLower(collectionName))
|
||||
if name == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
var kb *rag.PersistentKB
|
||||
cs.Mu.RLock()
|
||||
kb, ok := cs.Collections[name]
|
||||
ensure := cs.EnsureCollection
|
||||
cs.Mu.RUnlock()
|
||||
if !ok || kb == nil {
|
||||
if ensure == nil {
|
||||
xlog.Debug("internal RAG: no ensureCollection", "collection", name)
|
||||
return nil, nil, false
|
||||
}
|
||||
var created bool
|
||||
kb, created = ensure(name)
|
||||
if !created || kb == nil {
|
||||
xlog.Debug("internal RAG: ensure collection failed", "collection", name)
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
ragAdapter := &internalRAGAdapter{collection: name, kb: kb}
|
||||
compAdapter := &internalCompactionAdapter{collection: name, kb: kb}
|
||||
return ragAdapter, compAdapter, true
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/localrecall/rag"
|
||||
)
|
||||
|
||||
// CollectionList maps collection names to their persistent knowledge bases.
|
||||
type CollectionList map[string]*rag.PersistentKB
|
||||
|
||||
// State holds in-memory state for the collections API.
|
||||
type State struct {
|
||||
Mu sync.RWMutex
|
||||
Collections CollectionList
|
||||
SourceManager *rag.SourceManager
|
||||
EnsureCollection func(name string) (*rag.PersistentKB, bool) // get-or-create for internal RAG
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchResult is a single search result (content + metadata) for API responses.
|
||||
type SearchResult struct {
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Similarity float32 `json:"similarity,omitempty"`
|
||||
}
|
||||
|
||||
// SourceInfo is a single external source for a collection.
|
||||
type SourceInfo struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"` // minutes
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
// Backend is the interface used by REST handlers for collection operations.
|
||||
// It is implemented by in-process state (embedded) or by an HTTP client.
|
||||
type Backend interface {
|
||||
ListCollections() ([]string, error)
|
||||
CreateCollection(name string) error
|
||||
Upload(collection, filename string, fileBody io.Reader) (string, error)
|
||||
ListEntries(collection string) ([]string, error)
|
||||
GetEntryContent(collection, entry string) (content string, chunkCount int, err error)
|
||||
Search(collection, query string, maxResults int) ([]SearchResult, error)
|
||||
Reset(collection string) error
|
||||
DeleteEntry(collection, entry string) (remainingEntries []string, err error)
|
||||
AddSource(collection, url string, intervalMin int) error
|
||||
RemoveSource(collection, url string) error
|
||||
ListSources(collection string) ([]SourceInfo, error)
|
||||
EntryExists(collection, entry string) bool
|
||||
// GetEntryFilePath returns the filesystem path of the stored file for the
|
||||
// given entry. This is used to serve the original uploaded binary file.
|
||||
GetEntryFilePath(collection, entry string) (string, error)
|
||||
}
|
||||
|
||||
// Config holds the configuration for the in-process collections backend.
|
||||
type Config struct {
|
||||
LLMAPIURL string
|
||||
LLMAPIKey string
|
||||
LLMModel string
|
||||
CollectionDBPath string
|
||||
FileAssets string
|
||||
VectorEngine string
|
||||
EmbeddingModel string
|
||||
MaxChunkingSize int
|
||||
ChunkOverlap int
|
||||
DatabaseURL string
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/webui/collections"
|
||||
)
|
||||
|
||||
// Re-export types from the collections sub-package so existing webui code continues to work.
|
||||
type CollectionSearchResult = collections.SearchResult
|
||||
type CollectionSourceInfo = collections.SourceInfo
|
||||
type CollectionsBackend = collections.Backend
|
||||
type CollectionsState = collections.State
|
||||
type CollectionList = collections.CollectionList
|
||||
@@ -1,137 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/pkg/localrag"
|
||||
)
|
||||
|
||||
// collectionsBackendHTTP implements CollectionsBackend using the LocalRAG HTTP API.
|
||||
type collectionsBackendHTTP struct {
|
||||
client *localrag.Client
|
||||
}
|
||||
|
||||
var _ CollectionsBackend = (*collectionsBackendHTTP)(nil)
|
||||
|
||||
// NewCollectionsBackendHTTP returns a CollectionsBackend that delegates to the given HTTP client.
|
||||
func NewCollectionsBackendHTTP(client *localrag.Client) CollectionsBackend {
|
||||
return &collectionsBackendHTTP{client: client}
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) ListCollections() ([]string, error) {
|
||||
return b.client.ListCollections()
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) CreateCollection(name string) error {
|
||||
return b.client.CreateCollection(name)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) Upload(collection, filename string, fileBody io.Reader) (string, error) {
|
||||
tmpDir, err := os.MkdirTemp("", "localagi-upload")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
tmpPath := filepath.Join(tmpDir, filename)
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(out, fileBody); err != nil {
|
||||
out.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key, err := b.client.Store(collection, tmpPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) ListEntries(collection string) ([]string, error) {
|
||||
return b.client.ListEntries(collection)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) GetEntryContent(collection, entry string) (string, int, error) {
|
||||
return b.client.GetEntryContent(collection, entry)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) Search(collection, query string, maxResults int) ([]CollectionSearchResult, error) {
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
results, err := b.client.Search(collection, query, maxResults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CollectionSearchResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
out = append(out, CollectionSearchResult{
|
||||
ID: r.ID,
|
||||
Content: r.Content,
|
||||
Metadata: r.Metadata,
|
||||
Similarity: r.Similarity,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) Reset(collection string) error {
|
||||
return b.client.Reset(collection)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
return b.client.DeleteEntry(collection, entry)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) AddSource(collection, url string, intervalMin int) error {
|
||||
return b.client.AddSource(collection, url, intervalMin)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) RemoveSource(collection, url string) error {
|
||||
return b.client.RemoveSource(collection, url)
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) ListSources(collection string) ([]CollectionSourceInfo, error) {
|
||||
srcs, err := b.client.ListSources(collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]CollectionSourceInfo, 0, len(srcs))
|
||||
for _, s := range srcs {
|
||||
var lastUpdate time.Time
|
||||
if s.LastUpdate != "" {
|
||||
lastUpdate, _ = time.Parse(time.RFC3339, s.LastUpdate)
|
||||
}
|
||||
out = append(out, CollectionSourceInfo{
|
||||
URL: s.URL,
|
||||
UpdateInterval: s.UpdateInterval,
|
||||
LastUpdate: lastUpdate,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) GetEntryFilePath(collection, entry string) (string, error) {
|
||||
return "", fmt.Errorf("GetEntryFilePath is not supported via HTTP backend")
|
||||
}
|
||||
|
||||
func (b *collectionsBackendHTTP) EntryExists(collection, entry string) bool {
|
||||
entries, err := b.client.ListEntries(collection)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e == entry {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/webui/collections"
|
||||
)
|
||||
|
||||
// NewInProcessCollectionsBackend delegates to the collections sub-package.
|
||||
func NewInProcessCollectionsBackend(cfg *Config) (CollectionsBackend, *CollectionsState) {
|
||||
collCfg := &collections.Config{
|
||||
LLMAPIURL: cfg.LLMAPIURL,
|
||||
LLMAPIKey: cfg.LLMAPIKey,
|
||||
LLMModel: cfg.LLMModel,
|
||||
CollectionDBPath: cfg.CollectionDBPath,
|
||||
FileAssets: cfg.FileAssets,
|
||||
VectorEngine: cfg.VectorEngine,
|
||||
EmbeddingModel: cfg.EmbeddingModel,
|
||||
MaxChunkingSize: cfg.MaxChunkingSize,
|
||||
ChunkOverlap: cfg.ChunkOverlap,
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
}
|
||||
return collections.NewInProcessBackend(collCfg)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// APIResponse represents a standardized API response (LocalRecall contract).
|
||||
type collectionsAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *collectionsAPIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type collectionsAPIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
errCodeNotFound = "NOT_FOUND"
|
||||
errCodeInvalidRequest = "INVALID_REQUEST"
|
||||
errCodeInternalError = "INTERNAL_ERROR"
|
||||
errCodeUnauthorized = "UNAUTHORIZED"
|
||||
errCodeConflict = "CONFLICT"
|
||||
)
|
||||
|
||||
func collectionsSuccessResponse(message string, data interface{}) collectionsAPIResponse {
|
||||
return collectionsAPIResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func collectionsErrorResponse(code, message, details string) collectionsAPIResponse {
|
||||
return collectionsAPIResponse{
|
||||
Success: false,
|
||||
Error: &collectionsAPIError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// collectionsAPIKeyFromRequest returns the API key from the same sources as the main keyauth: Authorization, x-api-key, xi-api-key, cookie:token.
|
||||
func collectionsAPIKeyFromRequest(c *fiber.Ctx) string {
|
||||
if v := c.Get("Authorization"); v != "" {
|
||||
return strings.TrimPrefix(strings.TrimSpace(v), "Bearer ")
|
||||
}
|
||||
if v := c.Get("x-api-key"); v != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if v := c.Get("xi-api-key"); v != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if v := c.Cookies("token"); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RegisterCollectionRoutes mounts /api/collections* routes. backend is either from NewInProcessCollectionsBackend or NewCollectionsBackendHTTP.
|
||||
func (app *App) RegisterCollectionRoutes(webapp *fiber.App, cfg *Config, backend CollectionsBackend) {
|
||||
webapp.Post("/api/collections", app.createCollection(backend))
|
||||
webapp.Get("/api/collections", app.listCollections(backend))
|
||||
webapp.Post("/api/collections/:name/upload", app.uploadFile(backend))
|
||||
webapp.Get("/api/collections/:name/entries", app.listFiles(backend))
|
||||
webapp.Get("/api/collections/:name/entries/*", app.getEntryContent(backend))
|
||||
webapp.Post("/api/collections/:name/search", app.searchCollection(backend))
|
||||
webapp.Post("/api/collections/:name/reset", app.resetCollection(backend))
|
||||
webapp.Delete("/api/collections/:name/entry/delete", app.deleteEntryFromCollection(backend))
|
||||
webapp.Post("/api/collections/:name/sources", app.registerExternalSource(backend))
|
||||
webapp.Delete("/api/collections/:name/sources", app.removeExternalSource(backend))
|
||||
webapp.Get("/api/collections/:name/sources", app.listSources(backend))
|
||||
}
|
||||
|
||||
func collectionErrStatus(err error, collection string) int {
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if strings.Contains(err.Error(), "collection not found") {
|
||||
return fiber.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "entry not found") {
|
||||
return fiber.StatusNotFound
|
||||
}
|
||||
return fiber.StatusInternalServerError
|
||||
}
|
||||
|
||||
func (app *App) createCollection(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
var r struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.BodyParser(&r); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error()))
|
||||
}
|
||||
if err := backend.CreateCollection(r.Name); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to create collection", err.Error()))
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(collectionsSuccessResponse("Collection created successfully", map[string]interface{}{
|
||||
"name": r.Name,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) listCollections(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
collectionsList, err := backend.ListCollections()
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to list collections", err.Error()))
|
||||
}
|
||||
return c.JSON(collectionsSuccessResponse("Collections retrieved successfully", map[string]interface{}{
|
||||
"collections": collectionsList,
|
||||
"count": len(collectionsList),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) uploadFile(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
xlog.Error("Failed to read file", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Failed to read file", err.Error()))
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
xlog.Error("Failed to open file", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Failed to open file", err.Error()))
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := backend.Upload(name, file.Filename, f); err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
xlog.Error("Failed to store file", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to store file", err.Error()))
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
return c.JSON(collectionsSuccessResponse("File uploaded successfully", map[string]interface{}{
|
||||
"filename": file.Filename,
|
||||
"collection": name,
|
||||
"created_at": now,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) listFiles(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
entries, err := backend.ListEntries(name)
|
||||
if err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to list entries", err.Error()))
|
||||
}
|
||||
return c.JSON(collectionsSuccessResponse("Entries retrieved successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"entries": entries,
|
||||
"count": len(entries),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// getEntryContent handles GET /api/collections/:name/entries/:entry (Fiber uses * for the rest of path).
|
||||
func (app *App) getEntryContent(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
entryParam := c.Params("*")
|
||||
if entryParam == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", "entry path required"))
|
||||
}
|
||||
entry, err := url.PathUnescape(entryParam)
|
||||
if err != nil {
|
||||
entry = entryParam
|
||||
}
|
||||
|
||||
content, chunkCount, err := backend.GetEntryContent(name, entry)
|
||||
if err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
if strings.Contains(err.Error(), "entry not found") {
|
||||
return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Entry not found", fmt.Sprintf("Entry '%s' does not exist in collection '%s'", entry, name)))
|
||||
}
|
||||
return c.Status(fiber.StatusNotFound).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
if strings.Contains(err.Error(), "not implemented") || strings.Contains(err.Error(), "unsupported file type") {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(collectionsErrorResponse(errCodeInternalError, "Not supported", err.Error()))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to get entry content", err.Error()))
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("Entry content retrieved successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"entry": entry,
|
||||
"content": content,
|
||||
"chunk_count": chunkCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) searchCollection(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
var r struct {
|
||||
Query string `json:"query"`
|
||||
MaxResults int `json:"max_results"`
|
||||
}
|
||||
if err := c.BodyParser(&r); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error()))
|
||||
}
|
||||
|
||||
results, err := backend.Search(name, r.Query, r.MaxResults)
|
||||
if err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to search collection", err.Error()))
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("Search completed successfully", map[string]interface{}{
|
||||
"query": r.Query,
|
||||
"max_results": r.MaxResults,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) resetCollection(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
if err := backend.Reset(name); err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to reset collection", err.Error()))
|
||||
}
|
||||
return c.JSON(collectionsSuccessResponse("Collection reset successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"reset_at": time.Now().Format(time.RFC3339),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) deleteEntryFromCollection(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
var r struct {
|
||||
Entry string `json:"entry"`
|
||||
}
|
||||
if err := c.BodyParser(&r); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error()))
|
||||
}
|
||||
|
||||
remainingEntries, err := backend.DeleteEntry(name, r.Entry)
|
||||
if err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to remove entry", err.Error()))
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("Entry deleted successfully", map[string]interface{}{
|
||||
"deleted_entry": r.Entry,
|
||||
"remaining_entries": remainingEntries,
|
||||
"entry_count": len(remainingEntries),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) registerExternalSource(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
var r struct {
|
||||
URL string `json:"url"`
|
||||
UpdateInterval int `json:"update_interval"`
|
||||
}
|
||||
if err := c.BodyParser(&r); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error()))
|
||||
}
|
||||
if r.UpdateInterval < 1 {
|
||||
r.UpdateInterval = 60
|
||||
}
|
||||
|
||||
if err := backend.AddSource(name, r.URL, r.UpdateInterval); err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to register source", err.Error()))
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("External source registered successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"url": r.URL,
|
||||
"update_interval": r.UpdateInterval,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) removeExternalSource(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
var r struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.BodyParser(&r); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(collectionsErrorResponse(errCodeInvalidRequest, "Invalid request", err.Error()))
|
||||
}
|
||||
|
||||
if err := backend.RemoveSource(name, r.URL); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to remove source", err.Error()))
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("External source removed successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"url": r.URL,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
func (app *App) listSources(backend CollectionsBackend) func(c *fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
name := c.Params("name")
|
||||
srcs, err := backend.ListSources(name)
|
||||
if err != nil {
|
||||
if status := collectionErrStatus(err, name); status == fiber.StatusNotFound {
|
||||
return c.Status(status).JSON(collectionsErrorResponse(errCodeNotFound, "Collection not found", fmt.Sprintf("Collection '%s' does not exist", name)))
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(collectionsErrorResponse(errCodeInternalError, "Failed to list sources", err.Error()))
|
||||
}
|
||||
|
||||
sourcesList := make([]map[string]interface{}, 0, len(srcs))
|
||||
for _, source := range srcs {
|
||||
sourcesList = append(sourcesList, map[string]interface{}{
|
||||
"url": source.URL,
|
||||
"update_interval": source.UpdateInterval,
|
||||
"last_update": source.LastUpdate.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(collectionsSuccessResponse("Sources retrieved successfully", map[string]interface{}{
|
||||
"collection": name,
|
||||
"sources": sourcesList,
|
||||
"count": len(sourcesList),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/webui/collections"
|
||||
)
|
||||
|
||||
// CollectionsRAGProviderFromState delegates to the collections sub-package.
|
||||
func CollectionsRAGProviderFromState(cs *CollectionsState) func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return collections.RAGProviderFromState(cs)
|
||||
}
|
||||
|
||||
// CollectionsRAGProvider returns a provider that the pool can use when no LocalRAG URL is set.
|
||||
func (app *App) CollectionsRAGProvider() func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return CollectionsRAGProviderFromState(app.collectionsState)
|
||||
}
|
||||
@@ -4,13 +4,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DefaultChunkSize int
|
||||
Pool *state.AgentPool
|
||||
SkillsService *skills.Service
|
||||
ApiKeys []string
|
||||
LLMAPIURL string
|
||||
LLMAPIKey string
|
||||
@@ -18,17 +16,6 @@ type Config struct {
|
||||
StateDir string
|
||||
CustomActionsDir string
|
||||
ConversationStoreDuration time.Duration
|
||||
|
||||
// Collections / knowledge base (LocalRecall)
|
||||
CollectionDBPath string
|
||||
FileAssets string
|
||||
VectorEngine string
|
||||
EmbeddingModel string
|
||||
MaxChunkingSize int
|
||||
ChunkOverlap int
|
||||
DatabaseURL string
|
||||
// LocalRAGURL when set uses HTTP backend for collections API; when empty uses in-process backend.
|
||||
LocalRAGURL string
|
||||
}
|
||||
|
||||
type Option func(*Config)
|
||||
@@ -85,66 +72,12 @@ func WithPool(pool *state.AgentPool) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithSkillsService(svc *skills.Service) Option {
|
||||
return func(c *Config) {
|
||||
c.SkillsService = svc
|
||||
}
|
||||
}
|
||||
|
||||
func WithApiKeys(keys ...string) Option {
|
||||
return func(c *Config) {
|
||||
c.ApiKeys = keys
|
||||
}
|
||||
}
|
||||
|
||||
func WithCollectionDBPath(path string) Option {
|
||||
return func(c *Config) {
|
||||
c.CollectionDBPath = path
|
||||
}
|
||||
}
|
||||
|
||||
func WithFileAssets(path string) Option {
|
||||
return func(c *Config) {
|
||||
c.FileAssets = path
|
||||
}
|
||||
}
|
||||
|
||||
func WithVectorEngine(engine string) Option {
|
||||
return func(c *Config) {
|
||||
c.VectorEngine = engine
|
||||
}
|
||||
}
|
||||
|
||||
func WithEmbeddingModel(model string) Option {
|
||||
return func(c *Config) {
|
||||
c.EmbeddingModel = model
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaxChunkingSize(size int) Option {
|
||||
return func(c *Config) {
|
||||
c.MaxChunkingSize = size
|
||||
}
|
||||
}
|
||||
|
||||
func WithChunkOverlap(overlap int) Option {
|
||||
return func(c *Config) {
|
||||
c.ChunkOverlap = overlap
|
||||
}
|
||||
}
|
||||
|
||||
func WithDatabaseURL(url string) Option {
|
||||
return func(c *Config) {
|
||||
c.DatabaseURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func WithLocalRAGURL(url string) Option {
|
||||
return func(c *Config) {
|
||||
c.LocalRAGURL = url
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Apply(opts ...Option) {
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
|
||||
@@ -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,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';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 933 KiB |
@@ -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,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';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user