Compare commits

..

2 Commits

Author SHA1 Message Date
Ettore Di Giacinto 76d82b1f4c drop unused code, run compaction immediately at start
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 23:07:47 +01:00
Ettore Di Giacinto 0de2228ba6 chore: allow to choose how we store things in the vector database
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 22:22:51 +01:00
85 changed files with 1719 additions and 11574 deletions
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '>=1.26.0'
go-version: '>=1.17.0'
- name: Run tests
run: |
make tests
@@ -29,6 +29,6 @@ jobs:
uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '>=1.26.0'
go-version: '>=1.17.0'
- run: |
make tests-e2e
+1 -1
View File
@@ -1,5 +1,5 @@
# Final stage
FROM ubuntu:26.04
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
+4 -4
View File
@@ -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"
@@ -44,7 +44,7 @@ COPY --from=ui-builder /app/dist /work/webui/react-ui/dist
# Build the application
RUN CGO_ENABLED=0 go build -ldflags="$LDFLAGS" -o localagi ./
FROM ubuntu:26.04
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
@@ -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"]
+14 -34
View File
@@ -18,7 +18,7 @@ Try on [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-b
Create customizable AI assistants, automations, chat bots and agents that run 100% locally. No need for agentic Python libraries or cloud service keys, just bring your GPU (or even just CPU) 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. Create Agents with a couple of clicks, connect via MCP and give it skills with [skillserver](https://github.com/mudler/skillserver). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
## 🛡️ Take Back Your Privacy
@@ -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 |
-15
View File
@@ -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)
}
-355
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
-38
View File
@@ -3,7 +3,6 @@ package agent
import (
"encoding/json"
"os"
"strings"
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/types"
@@ -92,43 +91,6 @@ 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
+88 -315
View File
@@ -15,8 +15,6 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/cogito"
"github.com/mudler/cogito/clients"
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/core/action"
@@ -75,10 +73,6 @@ type Agent struct {
// 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 +89,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 +97,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 *types.ConversationMessage),
newMessagesSubscribers: options.newConversationsSubscribers,
sharedState: types.NewAgentSharedState(options.lastMessageDuration),
}
// Initialize observer if provided
@@ -180,19 +173,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 {
@@ -250,84 +230,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 +238,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,17 +246,14 @@ 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)
})
}
@@ -371,19 +270,6 @@ 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
}
@@ -483,7 +369,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 +428,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 +537,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 +617,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"
@@ -911,26 +805,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 +823,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 +840,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), "")
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 +882,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,23 +906,28 @@ 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.WithSinkState(
cogito.NewToolDefinition(
NoToolToCallTool{},
NoToolToCallArgs{},
"no_tool_to_call",
"Called when no other tool is needed to respond to the user",
),
),
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)
obs.Name = "action"
obs.Icon = "bolt"
obs.MakeLastProgressCompletion()
a.observer.Update(*obs)
}
// Use full ActionResult (including Metadata) from action result,
@@ -1160,19 +986,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
}
// 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),
})
}
if a.observer != nil && job.Obs != nil {
obs := a.observer.NewObservable()
obs.Name = "decision"
@@ -1201,8 +1014,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
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)
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
return cogito.ToolCallDecision{
Approved: false,
}
@@ -1228,24 +1040,22 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
msg,
}
job.Result.SetResponse("decided to initiate a new conversation")
finishedByCallback = true
finishErr = nil
job.Result.Finish(nil)
return cogito.ToolCallDecision{
Approved: false,
Approved: 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)
a.observer.Update(*obs)
}
return cogito.ToolCallDecision{
Approved: false,
@@ -1253,21 +1063,21 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
// 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{
@@ -1275,11 +1085,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
}
}
// Mark state tool-call observable as completed successfully
if stateObs != nil && a.observer != nil {
stateObs.MakeLastProgressCompletion()
a.observer.Update(*stateObs)
}
}
@@ -1302,8 +1107,8 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
})
job.Result.Conversation = conv
finishedByCallback = true
finishErr = nil
job.Result.Finish(nil)
}
return cogito.ToolCallDecision{
Approved: cont,
@@ -1318,12 +1123,11 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
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)
llmClient := cogito.NewOpenAILLM(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)
}
@@ -1338,33 +1142,11 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
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 +1164,6 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
return
}
if finishedByCallback {
job.Result.Finish(finishErr)
return
}
if userTool {
return
}
@@ -1458,12 +1235,8 @@ 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),
)
+1 -22
View File
@@ -211,34 +211,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
for _, s := range a.mcpSessions {
if extraSet[s] {
keep = append(keep, s)
} else {
s.Close()
}
s.Close()
}
a.mcpSessions = keep
}
-101
View File
@@ -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))
})
})
+2 -7
View File
@@ -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
}
+3 -105
View File
@@ -5,9 +5,7 @@ import (
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/cogito"
)
type Option func(*options) error
@@ -50,7 +48,6 @@ type options struct {
canStopItself bool
initiateConversations bool
forceReasoning bool
forceReasoningTool bool
enableGuidedTools bool
canPlan bool
disableSinkState bool
@@ -67,15 +64,11 @@ type options struct {
// 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 +79,12 @@ type options struct {
mcpServers []MCPServer
mcpStdioServers []MCPSTDIOServer
mcpPrepareScript string
extraMCPSessions []*mcp.ClientSession
newConversationsSubscribers []func(*types.ConversationMessage)
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 {
@@ -113,7 +94,6 @@ func (o *options) SeparatedMultimodalModel() bool {
func defaultOptions() *options {
return &options{
parallelJobs: 1,
maxAttempts: 1,
periodicRuns: 15 * time.Minute,
schedulerPollInterval: 30 * time.Second,
maxEvaluationLoops: 2,
@@ -162,11 +142,6 @@ var EnableGuidedTools = func(o *options) error {
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,29 +193,6 @@ 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 {
return func(o *options) error {
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
@@ -319,22 +271,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 +323,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
@@ -512,7 +440,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
}
}
@@ -585,33 +513,3 @@ func WithSchedulerStorePath(path string) Option {
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
}
}
+2 -23
View File
@@ -15,30 +15,9 @@ type agentSchedulerExecutor struct {
// 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
// Create a job for the reminder
reminderJob := types.NewJob(
types.WithText(innerMonologue),
types.WithText(fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)),
types.WithReasoningCallback(e.agent.options.reasoningCallback),
types.WithResultCallback(e.agent.options.resultCallback),
types.WithContext(ctx),
-9
View File
@@ -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)
}
-13
View File
@@ -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) {
+5 -33
View File
@@ -15,34 +15,6 @@ import (
"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})`)
@@ -130,9 +102,9 @@ func (s *openAISummarizer) Summarize(ctx context.Context, content string) (strin
}
// 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 {
func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error {
collection := client.Collection()
entries, err := client.ListEntries()
entries, err := client.Client.ListEntries(collection)
if err != nil {
return fmt.Errorf("list entries: %w", err)
}
@@ -192,7 +164,7 @@ func RunCompaction(ctx context.Context, client KBCompactionClient, period string
xlog.Warn("compaction: write temp file failed", "error", err)
continue
}
if err := client.Store(tmpPath); err != nil {
if err := client.Client.Store(collection, tmpPath); err != nil {
os.RemoveAll(tmpDir)
xlog.Warn("compaction: store failed", "key", key, "error", err)
continue
@@ -200,7 +172,7 @@ func RunCompaction(ctx context.Context, client KBCompactionClient, period string
os.RemoveAll(tmpDir)
for _, entry := range groupEntries {
if err := client.DeleteEntry(entry); err != nil {
if _, err := client.Client.DeleteEntry(collection, entry); err != nil {
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
}
}
@@ -210,7 +182,7 @@ func RunCompaction(ctx context.Context, client KBCompactionClient, period string
}
// 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) {
func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) {
// Run first compaction immediately on startup
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
+34 -133
View File
@@ -74,46 +74,36 @@ 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"`
PlanReviewerModel string `json:"plan_reviewer_model" form:"plan_reviewer_model"`
DisableSinkState bool `json:"disable_sink_state" form:"disable_sink_state"`
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"`
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"`
KBCompactionInterval string `json:"kb_compaction_interval" form:"kb_compaction_interval"`
KBCompactionSummarize bool `json:"kb_compaction_summarize" form:"kb_compaction_summarize"`
KBAutoSearch bool `json:"kb_auto_search" form:"kb_auto_search"`
KBAsTools bool `json:"kb_as_tools" form:"kb_as_tools"`
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
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"`
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
}
type AgentConfigMeta struct {
@@ -320,8 +310,8 @@ func NewAgentConfigMeta(
{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"},
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",
@@ -339,30 +329,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 +353,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",
@@ -435,16 +383,8 @@ func NewAgentConfigMeta(
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",
HelpText: "Enable agent to explain its reasoning process",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
@@ -455,14 +395,6 @@ func NewAgentConfigMeta(
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",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "parallel_jobs",
Label: "Parallel Jobs",
@@ -505,24 +437,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 +455,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",
@@ -595,8 +499,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
*Alias
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
MaxAttempts interface{} `json:"max_attempts"`
ParallelJobs interface{} `json:"parallel_jobs"`
ParallelJobs interface{} `json:"parallel_jobs"`
KnowledgeBaseResults interface{} `json:"kb_results"`
}{
Alias: (*Alias)(a),
@@ -608,10 +511,8 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
// 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 {
+61 -317
View File
@@ -12,68 +12,31 @@ 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/localrag"
"github.com/mudler/LocalAGI/pkg/utils"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/mudler/cogito"
"github.com/mudler/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
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 {
@@ -108,13 +71,13 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
func NewAgentPool(
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
LocalRAGAPI string,
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
connectors func(*AgentConfig) []Connector,
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.
@@ -136,34 +99,24 @@ func NewAgentPool(
defaultTranscriptionModel: defaultTranscriptionModel,
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
defaultTTSModel: defaultTTSModel,
localRAGAPI: LocalRAGAPI,
apiKey: apiKey,
agents: make(map[string]*Agent),
pool: make(map[string]AgentConfig),
agentStatus: make(map[string]*Status),
managers: make(map[string]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,
@@ -176,16 +129,16 @@ func NewAgentPool(
defaultTTSModel: defaultTTSModel,
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 +147,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.
@@ -227,22 +172,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)
@@ -292,11 +237,11 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
}
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
var manager sseLib.Manager
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
@@ -334,29 +279,29 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
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 +325,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,
@@ -398,7 +343,7 @@ 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),
@@ -420,23 +365,19 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
),
WithStateFile(stateFile),
WithCharacterFile(characterFile),
WithLLMAPIKey(effectiveAPIKey),
WithLLMAPIKey(a.apiKey),
WithTimeout(a.timeout),
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 +390,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 +404,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,
),
@@ -544,33 +479,26 @@ 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)
var ragClient *localrag.WrappedClient
if config.EnableKnowledgeBase {
ragClient = localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)
opts = append(opts, WithRAGDB(ragClient), EnableKnowledgeBase)
// Set KB auto search option (defaults to true for backward compatibility)
// For backward compatibility: if both new KB fields are false (zero values),
// assume this is an old config and default KBAutoSearch to true
kbAutoSearch := config.KBAutoSearch
if !config.KBAutoSearch && !config.KBAsTools {
// Both new fields are false, likely an old config - default to true for backward compatibility
kbAutoSearch = true
}
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
if config.KBAsTools {
// Inject KB wrapper actions if enabled
if config.KBAsTools && ragClient != nil {
kbResults := config.KnowledgeBaseResults
if kbResults <= 0 {
kbResults = 5
kbResults = 5 // Default
}
searchAction, addAction := NewKBWrapperActions(ragDB, kbResults)
searchAction, addAction := NewKBWrapperActions(ragClient, kbResults)
opts = append(opts, WithActions(searchAction, addAction))
}
}
@@ -587,14 +515,6 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
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 +523,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 +531,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,8 +547,8 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
}
}()
if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil {
go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model)
if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil {
go runCompactionTicker(ctx, ragClient, config, a.apiURL, a.apiKey, model)
}
xlog.Info("Starting connectors", "name", name, "config", config)
@@ -692,7 +560,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"))
}
@@ -755,115 +623,6 @@ func (a *AgentPool) Start(name string) error {
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))
@@ -901,21 +660,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 +689,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]
}
-5
View File
@@ -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
+1 -1
View File
@@ -31,7 +31,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
+15 -5
View File
@@ -11,16 +11,26 @@ services:
- /dev/dri
- /dev/kfd
postgres:
extends:
file: docker-compose.yaml
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+15 -5
View File
@@ -12,16 +12,26 @@ services:
- /dev/dri/card1
- /dev/dri/renderD129
postgres:
extends:
file: docker-compose.yaml
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+15 -5
View File
@@ -17,16 +17,26 @@ services:
count: 1
capabilities: [gpu]
postgres:
extends:
file: docker-compose.yaml
service: postgres
dind:
extends:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
service: localrecall
localrecall-healthcheck:
extends:
file: docker-compose.yaml
service: localrecall-healthcheck
localagi:
extends:
file: docker-compose.yaml
+33 -9
View File
@@ -25,7 +25,7 @@ services:
- backends:/backends
- images:/tmp/generated/images
postgres:
localrecall-postgres:
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
environment:
- POSTGRES_DB=localrecall
@@ -41,6 +41,34 @@ services:
timeout: 5s
retries: 5
localrecall:
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.4}
depends_on:
localrecall-postgres:
condition: service_healthy
localai:
condition: service_started
ports:
- 8080
environment:
- DATABASE_URL=postgresql://localrecall:localrecall@localrecall-postgres:5432/localrecall?sslmode=disable
- VECTOR_ENGINE=postgres
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
- FILE_ASSETS=/assets
- OPENAI_API_KEY=sk-1234567890
- OPENAI_BASE_URL=http://localai:8080
- HYBRID_SEARCH_BM25_WEIGHT=0.5
- HYBRID_SEARCH_VECTOR_WEIGHT=0.5
volumes:
- 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:
context: .
@@ -73,8 +101,8 @@ services:
depends_on:
localai:
condition: service_healthy
postgres:
condition: service_healthy
localrecall-healthcheck:
condition: service_completed_successfully
dind:
condition: service_healthy
build:
@@ -88,11 +116,8 @@ services:
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
- 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
@@ -101,12 +126,11 @@ services:
- "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:
localrag_assets:
localagi_pool:
+15 -48
View File
@@ -1,6 +1,6 @@
module github.com/mudler/LocalAGI
go 1.26.0
go 1.24.4
require (
github.com/Masterminds/sprig/v3 v3.3.0
@@ -11,28 +11,24 @@ require (
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.1-0.20260507074622-a7724fef6f81
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.8.2-0.20260214201734-da0d4ceb2b44
github.com/mudler/xlog v0.0.1
github.com/onsi/ginkgo/v2 v2.25.3
github.com/onsi/gomega v1.38.2
github.com/philippgille/chromem-go v0.7.0
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,8 +39,6 @@ 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
@@ -64,50 +58,22 @@ require (
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 +98,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 +118,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
)
+40 -138
View File
@@ -12,11 +12,8 @@ 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=
@@ -25,8 +22,6 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
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,8 +29,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=
@@ -83,8 +76,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,9 +87,6 @@ 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=
@@ -119,8 +107,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 +115,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 +135,13 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-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=
@@ -209,8 +165,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
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,22 +174,6 @@ 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=
@@ -241,25 +181,16 @@ github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5Pt
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 +201,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,8 +219,8 @@ 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/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -301,31 +230,25 @@ 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.1-0.20260507074622-a7724fef6f81 h1:8D9NJ/ikhsJCxUwbdzIzadw6RqDrW+L0FPqpQQSeux8=
github.com/mudler/localrecall v0.6.1-0.20260507074622-a7724fef6f81/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.8.2-0.20260206153401-a5346975d42b h1:LXHovZzNgP0n/oYEoO4zDt4k4CRvG0Owhu8x/OVGhYc=
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44 h1:joGszpItINnZdoL/0p2077Wz2xnxMGRSRgYN5mS7I4c=
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.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=
@@ -336,17 +259,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=
@@ -360,19 +284,12 @@ 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=
@@ -380,17 +297,13 @@ 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 +328,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=
@@ -442,18 +353,19 @@ 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=
@@ -462,12 +374,9 @@ 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 +384,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 +395,13 @@ 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/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-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 +411,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 +422,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,31 +435,27 @@ 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=
+101 -3
View File
@@ -1,9 +1,107 @@
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 conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
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,
apiURL,
apiKey,
stateDir,
localRAG,
services.Actions(map[string]string{
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"))
}
+10 -125
View File
@@ -93,8 +93,7 @@ func (c *WrappedClient) Store(s string) error {
}
defer os.Remove(f)
_, err = c.Client.Store(c.collection, f)
return err
return c.Client.Store(c.collection, f)
}
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
@@ -284,14 +283,10 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
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
}
@@ -472,13 +467,13 @@ func (c *Client) Reset(collection string) error {
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 +482,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 +505,14 @@ 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")
return parseAPIError(resp, body, "failed to upload file")
}
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)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Sources []SourceInfo `json:"sources"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Sources, nil
}
+4 -13
View File
@@ -8,8 +8,6 @@ import (
"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"
@@ -85,24 +83,17 @@ func (a *GenPDFAction) Run(ctx context.Context, sharedState *types.AgentSharedSt
// 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.MultiCell(0, 10, result.Title, "", "", false)
pdf.Ln(5)
}
// Add content: parse as markdown and render, or fall back to plain text
// Add content
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)
}
pdf.MultiCell(0, 10, result.Content, "", "", false)
// Save PDF
savedPath := filepath.Join(a.outputDir, filename)
@@ -129,7 +120,7 @@ func (a *GenPDFAction) Definition() types.ActionDefinition {
},
"content": {
Type: jsonschema.String,
Description: "Text or Markdown content to include in the PDF (headings, bold, lists, code blocks, etc. are rendered)",
Description: "Text content to include in the PDF document",
},
"filename": {
Type: jsonschema.String,
-412
View File
@@ -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, "")
}
}
}
-48
View File
@@ -161,52 +161,4 @@ var _ = Describe("GenPDFAction", func() {
// 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\", 23"
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))
})
})
-101
View File
@@ -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()
}
+24 -3
View File
@@ -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))
+181 -309
View File
@@ -4,9 +4,7 @@ import (
"bytes"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
@@ -20,7 +18,6 @@ import (
"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,116 @@ 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)
}
}
// coming from the generate_song action (local file paths)
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error(fmt.Sprintf("Error reading song file %s: %v", path, err))
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "audio"
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: filename,
InitialComment: "Generated song",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading song to Slack: %v", err))
}
}
}
// coming from the generate_pdf action (local file paths)
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error(fmt.Sprintf("Error reading PDF file %s: %v", path, err))
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: filename,
InitialComment: "Generated PDF document",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading PDF to Slack: %v", err))
}
}
}
}
return
}
// ImageData represents a single image with its metadata
@@ -532,6 +433,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 +464,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 +514,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 +565,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 +579,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 +592,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 +728,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 +740,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 +753,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,30 +787,18 @@ 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
// handle new conversations
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,
_, _, err := api.PostMessage(t.channelID,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(convertedContent, false),
slack.MsgOptionText(ccm.Message.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{
+43 -64
View File
@@ -22,7 +22,6 @@ 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/xstrings"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
@@ -42,7 +41,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 +232,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 +260,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 +306,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 +423,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 +445,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 +469,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)
@@ -723,6 +702,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 +738,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 +772,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()
}()
@@ -1036,7 +1016,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",
-106
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
-182
View File
@@ -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
}
+5 -49
View File
@@ -2,7 +2,6 @@ package webui
import (
"context"
"embed"
"encoding/json"
"fmt"
"net/http"
@@ -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 {
-304
View File
@@ -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
}
-185
View File
@@ -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
}
}
-18
View File
@@ -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
}
-55
View File
@@ -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
}
-12
View File
@@ -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
-137
View File
@@ -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
}
-22
View File
@@ -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)
}
-362
View File
@@ -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),
}))
}
}
-17
View File
@@ -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)
}
-67
View File
@@ -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)
-714
View File
@@ -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;
}
}
-44
View File
@@ -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

-714
View File
@@ -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;
}
}
-44
View File
@@ -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';
});
});
});
-354
View File
@@ -1,354 +0,0 @@
<!DOCTYPE html>
<html lang="en">
{{template "public/views/partials/header" .}}
<body>
<div class="login-page">
<div class="login-background">
<div class="bg-gradient"></div>
</div>
<div class="login-container">
<!-- Logo -->
<div class="login-logo">
<img src="/public/logo_1.png" alt="LocalAGI Logo" width="180">
</div>
<!-- Auth Card -->
<div class="login-card">
<div class="login-card-header">
<h2>Authorization Required</h2>
<p>Please enter your access token to continue</p>
</div>
<form id="login-form" onsubmit="login(); return false;">
<div class="form-group">
<label for="token">Access Token</label>
<div class="input-wrapper">
<span class="input-icon"><i class="fas fa-key"></i></span>
<input
type="password"
id="token"
name="token"
placeholder="Enter your token"
required
/>
</div>
</div>
<button type="submit" class="login-button">
<span>Login</span>
<i class="fas fa-arrow-right"></i>
</button>
<div id="error-message" class="error-message"></div>
</form>
<div class="login-footer">
<div class="security-badge">
<i class="fas fa-shield-alt"></i>
<span>Instance is token protected</span>
</div>
<p class="time-display">Current time (UTC): <span id="current-time">{{.CurrentDate}}</span></p>
</div>
</div>
</div>
</div>
<style>
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
width: 100%;
padding: 2rem;
}
.login-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.login-background .bg-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at top left, rgba(56, 189, 248, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(56, 189, 248, 0.04) 0%, transparent 50%),
var(--color-bg-primary);
}
.login-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 380px;
}
.login-logo {
margin-bottom: 1.5rem;
text-align: center;
animation: fadeInDown 0.5s ease-out;
}
.login-logo img {
max-width: 180px;
height: auto;
}
.login-card {
width: 100%;
background-color: var(--color-bg-secondary);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
padding: 2rem;
animation: fadeInUp 0.5s ease-out 0.1s both;
}
.login-card-header {
text-align: center;
margin-bottom: 1.5rem;
}
.login-card-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.login-card-header p {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--color-text-secondary);
font-weight: 500;
font-size: 0.875rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
z-index: 1;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.95rem;
transition: all var(--duration-fast) var(--ease-default);
}
.form-group input::placeholder {
color: var(--color-text-muted);
}
.form-group input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.login-button {
width: 100%;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.login-button:hover {
background-color: var(--color-primary-hover);
}
.login-button:active {
transform: translateY(1px);
}
.login-button i {
transition: transform var(--duration-fast) var(--ease-default);
}
.login-button:hover i {
transform: translateX(3px);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background-color: var(--color-error-light);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.875rem;
display: none;
align-items: center;
gap: 0.5rem;
animation: shake 0.4s ease-out;
}
.login-footer {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.time-display {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.time-display span {
color: var(--color-text-secondary);
font-family: monospace;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 1.5rem;
}
.login-logo img {
max-width: 150px;
}
}
</style>
<script>
function login() {
var token = document.getElementById('token');
var errorMsg = document.getElementById('error-message');
var tokenValue = token.value.trim();
if (!tokenValue) {
errorMsg.innerHTML = '<i class="fas fa-exclamation-circle"></i> Please enter a valid token';
errorMsg.style.display = 'flex';
errorMsg.style.animation = 'none';
errorMsg.offsetHeight;
errorMsg.style.animation = 'shake 0.4s ease-out';
token.focus();
return;
}
var date = new Date();
date.setTime(date.getTime() + (24 * 60 * 60 * 1000));
document.cookie = 'token=' + tokenValue + '; expires=' + date.toGMTString() + '; path=/';
var button = document.querySelector('.login-button');
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Authenticating...</span>';
button.style.opacity = '0.8';
setTimeout(function() {
window.location.reload();
}, 800);
}
function updateCurrentTime() {
var timeElement = document.getElementById('current-time');
if (timeElement) {
var now = new Date();
var year = now.getUTCFullYear();
var month = String(now.getUTCMonth() + 1).padStart(2, '0');
var day = String(now.getUTCDate()).padStart(2, '0');
var hours = String(now.getUTCHours()).padStart(2, '0');
var minutes = String(now.getUTCMinutes()).padStart(2, '0');
var seconds = String(now.getUTCSeconds()).padStart(2, '0');
timeElement.textContent = year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
}
}
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
</script>
</body>
</html>
-94
View File
@@ -1,94 +0,0 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* LocalAGI Theme - CSS Variables System */
:root {
/* Background Colors */
--color-bg-primary: #0F172A;
--color-bg-secondary: #1E293B;
--color-bg-tertiary: #1E293B;
--color-bg-overlay: rgba(15, 23, 42, 0.8);
/* Brand Colors - Primary Palette */
--color-primary: #38BDF8;
--color-primary-hover: #0EA5E9;
--color-primary-active: #0284C7;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(56, 189, 248, 0.08);
--color-primary-border: rgba(56, 189, 248, 0.15);
/* Secondary Colors */
--color-secondary: #14B8A6;
--color-secondary-hover: #0D9488;
--color-secondary-light: rgba(20, 184, 166, 0.1);
/* Accent Colors */
--color-accent: #8B5CF6;
--color-accent-hover: #7C3AED;
--color-accent-light: rgba(139, 92, 246, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
/* Text Colors */
--color-text-primary: #E5E7EB;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-text-disabled: #475569;
--color-text-inverse: #0F172A;
/* Border Colors */
--color-border: rgba(148, 163, 184, 0.12);
--color-border-subtle: rgba(148, 163, 184, 0.08);
--color-border-strong: rgba(56, 189, 248, 0.2);
--color-border-focus: rgba(56, 189, 248, 0.3);
/* Status Colors */
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-info: #38BDF8;
--color-info-light: rgba(56, 189, 248, 0.1);
/* Gradient Definitions */
--gradient-primary: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
--gradient-subtle: linear-gradient(135deg, rgba(56, 189, 248, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%);
--gradient-text: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
/* Shadows */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-glow: 0 0 0 1px rgba(56, 189, 248, 0.1), 0 0 8px rgba(56, 189, 248, 0.15);
/* Animation Timing */
--duration-fast: 150ms;
--duration-normal: 200ms;
--duration-slow: 300ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.5;
}
</style>
+20 -22
View File
@@ -11,12 +11,12 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.3",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"react-router-dom": "^7.13.0",
"vite": "^7.3.1",
@@ -118,17 +118,17 @@
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="],
"@eslint/config-array": ["@eslint/config-array@0.23.1", "", { "dependencies": { "@eslint/object-schema": "^3.0.1", "debug": "^4.3.1", "minimatch": "^10.1.1" } }, "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.2", "", { "dependencies": { "@eslint/core": "^1.1.0" } }, "sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ=="],
"@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="],
"@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.1", "", {}, "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -138,6 +138,10 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.2", "", {}, "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -208,21 +212,17 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"browserslist": ["browserslist@4.24.4", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A=="],
@@ -248,17 +248,17 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="],
"eslint": ["eslint@10.0.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.0", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.0", "eslint-visitor-keys": "^5.0.0", "espree": "^11.1.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.1.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.0", "", { "peerDependencies": { "eslint": ">=9" } }, "sha512-ZYvmh7VfVgqR/7wR71I3Zl6hK/C5CcxdWYKZSpHawS5JCNgE4efhQWg/+/WPpgGAp9Ngp/rRZYyaIwmPQBq/lA=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-scope": ["eslint-scope@9.1.0", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
@@ -328,7 +328,7 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -416,8 +416,6 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/config-helpers/@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
"@types/babel__core/@babel/parser": ["@babel/parser@7.27.0", "", { "dependencies": { "@babel/types": "^7.27.0" }, "bin": "./bin/babel-parser.js" }, "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg=="],
+3 -3
View File
@@ -16,12 +16,12 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.3",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"react-router-dom": "^7.13.0",
"vite": "^7.3.1"
+345 -1103
View File
File diff suppressed because it is too large Load Diff
+88 -97
View File
@@ -1,14 +1,10 @@
import { useState } from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useTheme } from './contexts/ThemeContext'
import ThemeToggle from './components/ThemeToggle'
import { Outlet, Link } from 'react-router-dom'
import './App.css'
function App() {
const [toast, setToast] = useState({ visible: false, message: '', type: 'success' });
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
const { isReady } = useTheme();
// Show toast notification
const showToast = (message, type = 'success') => {
@@ -18,111 +14,106 @@ function App() {
}, 3000);
};
// Navigation items
const navItems = [
{ path: '/', icon: 'fas fa-home', label: 'Home' },
{ path: '/agents', icon: 'fas fa-users', label: 'Agents' },
{ path: '/actions-playground', icon: 'fas fa-bolt', label: 'Actions' },
{ path: '/skills', icon: 'fas fa-book', label: 'Skills' },
{ path: '/knowledge', icon: 'fas fa-database', label: 'Knowledge base' },
{ path: '/group-create', icon: 'fas fa-users-cog', label: 'Groups' },
];
// Check if route is active
const isActive = (path) => {
if (path === '/') {
return location.pathname === '/';
}
return location.pathname.startsWith(path);
// Toggle mobile menu
const toggleMobileMenu = () => {
setMobileMenuOpen(!mobileMenuOpen);
};
// Don't render until theme is ready
if (!isReady) {
return null;
}
return (
<div className="app-layout">
{/* Mobile Overlay */}
<div className="app-container">
{/* Navigation Menu */}
<nav className="main-nav">
<div className="container">
<div className="nav-content">
<div className="logo-container">
{/* Logo */}
<Link to="/" className="logo-link">
<div className="logo-image-container">
<img src="/app/logo_2.png" alt="Logo" className="logo-image" />
</div>
{/* <span className="logo-text">LocalAGI</span> */}
</Link>
</div>
<div className="desktop-menu">
<ul className="nav-links">
<li>
<Link to="/" className="nav-link">
<i className="fas fa-home mr-2"></i> Home
</Link>
</li>
<li>
<Link to="/agents" className="nav-link">
<i className="fas fa-users mr-2"></i> Agent List
</Link>
</li>
<li>
<Link to="/actions-playground" className="nav-link">
<i className="fas fa-bolt mr-2"></i> Actions Playground
</Link>
</li>
<li>
<Link to="/group-create" className="nav-link">
<i className="fas fa-users-cog mr-2"></i> Create Agent Group
</Link>
</li>
</ul>
</div>
<div className="">
<span className="status-indicator"></span>
<span className="status-text">State: <span className="status-value">active</span></span>
</div>
<div className="mobile-menu-toggle" onClick={toggleMobileMenu}>
<i className="fas fa-bars"></i>
</div>
</div>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="mobile-overlay" onClick={() => setMobileMenuOpen(false)} />
)}
{/* Sidebar */}
<aside className={`sidebar ${mobileMenuOpen ? 'mobile-open' : ''}`}>
{/* Sidebar Header - Logo Only */}
<div className="sidebar-header">
<Link to="/" className="sidebar-logo">
<img
src="/app/logo_1.png"
alt="LocalAGI"
className="sidebar-logo-img"
/>
</Link>
</div>
{/* Navigation */}
<nav className="sidebar-nav">
<ul className="nav-list">
{navItems.map((item) => (
<li key={item.path} className="nav-item">
<Link
to={item.path}
className={`nav-link ${isActive(item.path) ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(false)}
>
<span className="nav-icon">
<i className={item.icon} />
</span>
<span className="nav-label">{item.label}</span>
</Link>
</li>
))}
<div className="mobile-menu">
<ul className="mobile-nav-links">
<li>
<Link to="/" className="mobile-nav-link" onClick={() => setMobileMenuOpen(false)}>
<i className="fas fa-home mr-2"></i> Home
</Link>
</li>
<li>
<Link to="/agents" className="mobile-nav-link" onClick={() => setMobileMenuOpen(false)}>
<i className="fas fa-users mr-2"></i> Agent List
</Link>
</li>
<li>
<Link to="/actions-playground" className="mobile-nav-link" onClick={() => setMobileMenuOpen(false)}>
<i className="fas fa-bolt mr-2"></i> Actions Playground
</Link>
</li>
<li>
<Link to="/group-create" className="mobile-nav-link" onClick={() => setMobileMenuOpen(false)}>
<i className="fas fa-users-cog mr-2"></i> Create Agent Group
</Link>
</li>
</ul>
</nav>
{/* Sidebar Footer */}
<div className="sidebar-footer">
<div className="sidebar-status">
<span className="status-dot" />
<span className="status-text">
System <strong>Active</strong>
</span>
</div>
<ThemeToggle />
</div>
</aside>
{/* Main Area */}
<div className="main-area">
{/* Mobile Header */}
<header className="mobile-header">
<button className="mobile-menu-btn" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<i className="fas fa-bars" />
</button>
<span className="mobile-title">LocalAGI</span>
<div className="mobile-spacer" />
</header>
{/* Main Content */}
<main className="main-content">
<div className="content-wrapper">
<Outlet context={{ showToast }} />
</div>
</main>
</div>
)}
{/* Toast Notification */}
{toast.visible && (
<div className={`toast ${toast.type}`}>
<i className={`fas ${
toast.type === 'success' ? 'fa-check-circle' :
toast.type === 'error' ? 'fa-exclamation-circle' :
'fa-info-circle'
}`} />
<span>{toast.message}</span>
</div>
)}
{/* Main Content Area */}
<main className="main-content">
<div className="container">
<Outlet context={{ showToast }} />
</div>
</main>
</div>
)
}
-99
View File
@@ -1,99 +0,0 @@
import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
const Sidebar = ({ children }) => {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
const navItems = [
{ path: '/', icon: 'fas fa-home', label: 'Home' },
{ path: '/agents', icon: 'fas fa-users', label: 'Agents' },
{ path: '/actions-playground', icon: 'fas fa-bolt', label: 'Actions' },
{ path: '/group-create', icon: 'fas fa-users-cog', label: 'Groups' },
];
const isActive = (path) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
const toggleMobile = () => setMobileOpen(!mobileOpen);
const closeMobile = () => setMobileOpen(false);
return (
<div className={`app-layout ${collapsed ? 'sidebar-collapsed' : ''}`}>
{/* Mobile Overlay */}
{mobileOpen && <div className="sidebar-overlay" onClick={closeMobile} />}
{/* Sidebar */}
<aside className={`sidebar ${mobileOpen ? 'mobile-open' : ''}`}>
{/* Logo */}
<div className="sidebar-header">
<Link to="/" className="sidebar-logo" onClick={closeMobile}>
<div className="logo-icon">
<img src="/app/logo_1.png" alt="LocalAGI" />
</div>
{!collapsed && <span className="logo-text">LocalAGI</span>}
</Link>
<button
className="sidebar-toggle desktop-only"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<i className={`fas fa-chevron-${collapsed ? 'right' : 'left'}`} />
</button>
</div>
{/* Navigation */}
<nav className="sidebar-nav">
<ul className="nav-list">
{navItems.map((item) => (
<li key={item.path} className="nav-item">
<Link
to={item.path}
className={`nav-link ${isActive(item.path) ? 'active' : ''}`}
onClick={closeMobile}
title={collapsed ? item.label : ''}
>
<i className={item.icon} />
{!collapsed && <span className="nav-label">{item.label}</span>}
</Link>
</li>
))}
</ul>
</nav>
{/* Status Footer */}
<div className="sidebar-footer">
<div className="status-indicator-wrapper">
<span className="status-dot" />
{!collapsed && (
<span className="status-label">
System <strong>Active</strong>
</span>
)}
</div>
</div>
</aside>
{/* Main Area */}
<div className="main-area">
{/* Mobile Header */}
<header className="mobile-header">
<button className="mobile-menu-btn" onClick={toggleMobile}>
<i className="fas fa-bars" />
</button>
<span className="mobile-title">LocalAGI</span>
</header>
{/* Page Content */}
<main className="content-area">
{children}
</main>
</div>
</div>
);
};
export default Sidebar;
@@ -1,25 +0,0 @@
import { useTheme } from '../contexts/ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme, isDark } = useTheme();
return (
<button
className="theme-toggle"
onClick={toggleTheme}
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-label={`Current theme: ${theme}. Click to toggle.`}
>
<div className="theme-toggle-track">
<div className={`theme-toggle-thumb ${isDark ? 'dark' : 'light'}`}>
<i className={`fas ${isDark ? 'fa-moon' : 'fa-sun'}`} />
</div>
</div>
<span className="theme-toggle-label">
{isDark ? 'Dark' : 'Light'}
</span>
</button>
);
}
export default ThemeToggle;
@@ -1,63 +0,0 @@
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark');
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Check for saved preference or system preference
const savedTheme = localStorage.getItem('localagi-theme');
if (savedTheme) {
setTheme(savedTheme);
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
setTheme('light');
}
setIsReady(true);
}, []);
useEffect(() => {
if (isReady) {
// Apply theme to document
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('localagi-theme', theme);
}
}, [theme, isReady]);
const toggleTheme = () => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
};
const setDarkMode = () => setTheme('dark');
const setLightMode = () => setTheme('light');
const value = {
theme,
isDark: theme === 'dark',
isLight: theme === 'light',
toggleTheme,
setDarkMode,
setLightMode,
isReady
};
// Don't render until theme is determined to prevent flash
if (!isReady) {
return null;
}
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+2 -51
View File
@@ -11,15 +11,11 @@ export function useChat(agentName) {
const [messages, setMessages] = useState([]);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const [streamReasoning, setStreamReasoning] = useState('');
const [streamContent, setStreamContent] = useState('');
const [streamToolCalls, setStreamToolCalls] = useState([]);
const processedMessageIds = useRef(new Set());
const processedStreamIds = useRef(new Set());
const localMessageContents = useRef(new Set()); // Track locally added message contents
// Use SSE hook to receive real-time messages
const { messages: sseMessages, statusUpdates, errorMessages, streamEvents, isConnected } = useSSE(agentName);
const { messages: sseMessages, statusUpdates, errorMessages, isConnected } = useSSE(agentName);
// Process SSE messages into chat messages
useEffect(() => {
@@ -79,14 +75,8 @@ export function useChat(agentName) {
if (statusData.status === 'processing') {
setSending(true);
setStreamReasoning('');
setStreamContent('');
setStreamToolCalls([]);
} else if (statusData.status === 'completed') {
setSending(false);
setStreamReasoning('');
setStreamContent('');
setStreamToolCalls([]);
}
} catch (err) {
console.error('Error processing status update:', err);
@@ -112,42 +102,6 @@ export function useChat(agentName) {
}
}, [errorMessages]);
// Process stream events (reasoning, content, tool_call, done)
useEffect(() => {
if (!streamEvents || streamEvents.length === 0) return;
const latestEvent = streamEvents[streamEvents.length - 1];
if (processedStreamIds.current.has(latestEvent.id)) return;
processedStreamIds.current.add(latestEvent.id);
const data = latestEvent.content;
if (data.type === 'reasoning') {
setStreamReasoning(prev => prev + (data.content || ''));
} else if (data.type === 'content') {
setStreamContent(prev => prev + (data.content || ''));
} else if (data.type === 'tool_call') {
const name = data.tool_name || '';
const args = data.tool_args || '';
if (name) {
// Reset reasoning and content when a new tool call starts —
// each iteration gets its own thinking block
setStreamReasoning('');
setStreamContent('');
}
setStreamToolCalls(prev => {
if (name) {
return [...prev, { name, args }];
}
if (prev.length === 0) return prev;
const updated = [...prev];
updated[updated.length - 1] = { ...updated[updated.length - 1], args: updated[updated.length - 1].args + args };
return updated;
});
} else if (data.type === 'done') {
// Stream complete — content finalized by json_message event
}
}, [streamEvents]);
// Send a message to the agent
const sendMessage = useCallback(async (content) => {
if (!agentName || !content) return false;
@@ -206,9 +160,6 @@ export function useChat(agentName) {
sending,
error,
isConnected,
streamReasoning,
streamContent,
streamToolCalls,
sendMessage,
clearChat,
clearError,
-19
View File
@@ -10,7 +10,6 @@ export function useSSE(agentName) {
const [messages, setMessages] = useState([]);
const [statusUpdates, setStatusUpdates] = useState([]);
const [errorMessages, setErrorMessages] = useState([]);
const [streamEvents, setStreamEvents] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const eventSourceRef = useRef(null);
@@ -81,23 +80,6 @@ export function useSSE(agentName) {
}
});
// Handle 'stream_event' event (reasoning, content, tool_call, done)
eventSource.addEventListener('stream_event', (event) => {
try {
const data = JSON.parse(event.data);
const timestamp = data.timestamp || new Date().toISOString();
setStreamEvents(prev => [...prev, {
id: `stream-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type: data.type,
content: data,
timestamp,
}]);
} catch (error) {
console.error('Error parsing stream event:', error);
}
});
// Handle 'error' event
eventSource.addEventListener('json_error', (event) => {
try {
@@ -142,7 +124,6 @@ export function useSSE(agentName) {
messages,
statusUpdates,
errorMessages,
streamEvents,
isConnected,
reconnect,
};
+1 -4
View File
@@ -2,7 +2,6 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import { ThemeProvider } from './contexts/ThemeContext'
import './theme.css'
import './index.css'
import './App.css'
@@ -21,8 +20,6 @@ document.head.appendChild(fontAwesomeLink);
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
<RouterProvider router={router} />
</StrictMode>,
)
+30 -30
View File
@@ -14,10 +14,10 @@ function AgentSettings() {
// Update document title
useEffect(() => {
if (name) {
document.title = `${name} - Settings - LocalAGI`;
document.title = `Agent Settings: ${name} - LocalAGI`;
}
return () => {
document.title = 'LocalAGI';
document.title = 'LocalAGI'; // Reset title when component unmounts
};
}, [name]);
@@ -35,12 +35,14 @@ function AgentSettings() {
useEffect(() => {
const fetchMetadata = async () => {
try {
// Fetch metadata from the dedicated endpoint
const response = await agentApi.getAgentConfigMetadata();
if (response) {
setMetadata(response);
}
} catch (error) {
console.error('Error fetching metadata:', error);
// Continue without metadata, the form will use default fields
}
};
@@ -50,10 +52,11 @@ function AgentSettings() {
// Load agent data when component mounts
useEffect(() => {
if (agent) {
// Set form data from agent config
setFormData({
...formData,
...agent,
name: name
name: name // Ensure name is set correctly
});
}
}, [agent]);
@@ -107,7 +110,7 @@ function AgentSettings() {
return (
<div className="settings-container">
<div className="loading">
<div className="loader" />
<i className="fas fa-spinner fa-spin"></i>
<p>Loading agent settings...</p>
</div>
</div>
@@ -118,7 +121,7 @@ function AgentSettings() {
return (
<div className="settings-container">
<div className="error">
<i className="fas fa-exclamation-triangle" />
<i className="fas fa-exclamation-triangle"></i>
<p>{error}</p>
</div>
</div>
@@ -127,47 +130,44 @@ function AgentSettings() {
return (
<div className="settings-container">
{/* Page Header */}
<header className="page-header">
<div className="header-title-section">
<div className="agent-title-wrapper">
<h1 className="agent-name">{name}</h1>
<span className={`status-badge ${agent?.active ? 'status-active' : 'status-paused'}`}>
{agent?.active ? 'Active' : 'Paused'}
</span>
</div>
<p className="agent-subtitle">Configure agent behavior, models, and connections</p>
</div>
<h1>
<i className="fas fa-cog"></i> Agent Settings - {name}
</h1>
<div className="header-actions">
<button
className={`action-btn ${agent?.active ? 'warning' : 'success'}`}
onClick={handleToggleStatus}
>
<i className={`fas ${agent?.active ? 'fa-pause' : 'fa-play'}`} />
{agent?.active ? 'Pause Agent' : 'Start Agent'}
{agent?.active ? (
<><i className="fas fa-pause"></i> Pause Agent</>
) : (
<><i className="fas fa-play"></i> Start Agent</>
)}
</button>
<button
className="action-btn delete-btn"
onClick={handleDelete}
>
<i className="fas fa-trash" />
Delete
<i className="fas fa-trash"></i> Delete Agent
</button>
</div>
</header>
{/* Settings Content */}
<div className="settings-content">
<AgentForm
isEdit={true}
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
loading={loading}
submitButtonText="Save Changes"
metadata={metadata}
/>
{/* Agent Configuration Form Section */}
<div className="section-box">
<AgentForm
isEdit={true}
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
loading={loading}
submitButtonText="Save Changes"
metadata={metadata}
/>
</div>
</div>
</div>
);
+321 -269
View File
@@ -1,18 +1,12 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import 'highlight.js/styles/monokai.css';
import CollapsibleRawSections from '../components/CollapsibleRawSections';
hljs.registerLanguage('json', json);
function ObservableSummary({ observable }) {
// --- CREATION SUMMARIES ---
const creation = observable?.creation || {};
const completion = observable?.completion || {};
// Chat message summary
// ChatCompletionRequest summary
let creationChatMsg = '';
// Prefer chat_completion_message if present (for jobs/top-level containers)
if (creation?.chat_completion_message && creation.chat_completion_message.content) {
creationChatMsg = creation.chat_completion_message.content;
} else {
@@ -22,90 +16,98 @@ function ObservableSummary({ observable }) {
creationChatMsg = lastMsg?.content || '';
}
}
// TODO:: Probably we have an array of two objects: [{type:"text", ...}, {type:"image_url", image_url: {url:"..."}}]
// We could display the image and text along with multimedia icon
if (typeof creationChatMsg === 'object') {
console.log("Multimedia message?", creationChatMsg);
creationChatMsg = 'Multimedia message';
}
// Function definition summary
// FunctionDefinition summary
let creationFunctionDef = '';
if (creation?.function_definition?.name) {
creationFunctionDef = `Function: ${creation.function_definition.name}`;
}
// Function params summary
// FunctionParams summary
let creationFunctionParams = '';
if (creation?.function_params && Object.keys(creation.function_params).length > 0) {
creationFunctionParams = `Params: ${JSON.stringify(creation.function_params)}`;
}
// Completion summary
// --- COMPLETION SUMMARIES ---
const completion = observable?.completion || {};
// ChatCompletionResponse summary
let completionChatMsg = '';
let chatCompletion = completion?.chat_completion_response;
if (!chatCompletion && Array.isArray(completion?.conversation) && completion.conversation.length > 0) {
chatCompletion = {
choices: completion.conversation.map(m => ({ message: m }))
};
chatCompletion = { choices: completion.conversation.map(m => {
return { message: m }
}) }
}
if (chatCompletion && Array.isArray(chatCompletion.choices) && chatCompletion.choices.length > 0) {
if (
chatCompletion &&
Array.isArray(chatCompletion.choices) &&
chatCompletion.choices.length > 0
) {
const lastChoice = chatCompletion.choices[chatCompletion.choices.length - 1];
// Prefer tool_call summary if present
let toolCallSummary = '';
const toolCalls = lastChoice?.message?.tool_calls;
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const toolCallSummary = toolCalls.map(tc => {
toolCallSummary = toolCalls.map(tc => {
let args = '';
// For OpenAI-style, arguments are in tc.function.arguments, function name in tc.function.name
if (tc.function && tc.function.arguments) {
try {
args = typeof tc.function.arguments === 'string'
? tc.function.arguments
: JSON.stringify(tc.function.arguments);
args = typeof tc.function.arguments === 'string' ? tc.function.arguments : JSON.stringify(tc.function.arguments);
} catch (e) {
args = '[Unserializable]';
args = '[Unserializable arguments]';
}
}
const toolName = tc.function?.name || tc.name || 'unknown';
return `${toolName}(${args})`;
}).join(', ');
completionChatMsg = { toolCallSummary, message: lastChoice?.message?.content || '' };
} else {
completionChatMsg = lastChoice?.message?.content || '';
return `Tool call: ${toolName}(${args})`;
}).join('\n');
}
completionChatMsg = lastChoice?.message?.content || '';
// Attach toolCallSummary to completionChatMsg for rendering
if (toolCallSummary) {
completionChatMsg = { toolCallSummary, message: completionChatMsg };
}
// Else, it's just a string
}
// Action result summary
// ActionResult summary
let completionActionResult = '';
if (completion?.action_result) {
completionActionResult = String(completion.action_result).slice(0, 100);
completionActionResult = `Action Result: ${String(completion.action_result).slice(0, 100)}`;
}
// Agent state summary
// AgentState summary
let completionAgentState = '';
if (completion?.agent_state) {
completionAgentState = JSON.stringify(completion.agent_state);
completionAgentState = `Agent State: ${JSON.stringify(completion.agent_state)}`;
}
// Error summary
let completionError = '';
if (completion?.error) {
completionError = completion.error;
completionError = `Error: ${completion.error}`;
}
// Filter result summary
let completionFilter = '';
if (completion?.filter_result) {
if (completion.filter_result?.has_triggers && !completion.filter_result?.triggered_by) {
completionFilter = 'Failed to match triggers';
completionFilter = 'Failed to match any triggers';
} else if (completion.filter_result?.triggered_by) {
completionFilter = `Triggered by ${completion.filter_result.triggered_by}`;
}
if (completion?.filter_result?.failed_by) {
completionFilter += `${completionFilter ? ', ' : ''}Failed by ${completion.filter_result.failed_by}`;
}
if (completion?.filter_result?.failed_by)
completionFilter = `${completionFilter ? completionFilter + ', ' : ''}Failed by ${completion.filter_result.failed_by}`;
}
// Check if any summary exists
// Only show if any summary is present
if (!creationChatMsg && !creationFunctionDef && !creationFunctionParams &&
!completionChatMsg && !completionActionResult &&
!completionAgentState && !completionError && !completionFilter) {
@@ -113,170 +115,120 @@ function ObservableSummary({ observable }) {
}
return (
<div className="observable-summary">
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, margin: '2px 0 0 0' }}>
{/* CREATION */}
{creationChatMsg && (
<div className="observable-summary-item creation" title={creationChatMsg}>
<i className="fas fa-comment-dots" />
<span>{creationChatMsg}</span>
<div title={creationChatMsg} style={{ display: 'flex', alignItems: 'center', color: '#cfc', fontSize: 14 }}>
<i className="fas fa-comment-dots" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{creationChatMsg}</span>
</div>
)}
{creationFunctionDef && (
<div className="observable-summary-item creation" title={creationFunctionDef}>
<i className="fas fa-code" />
<span>{creationFunctionDef}</span>
<div title={creationFunctionDef} style={{ display: 'flex', alignItems: 'center', color: '#cfc', fontSize: 14 }}>
<i className="fas fa-code" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{creationFunctionDef}</span>
</div>
)}
{creationFunctionParams && (
<div className="observable-summary-item creation" title={creationFunctionParams}>
<i className="fas fa-sliders-h" />
<span>{creationFunctionParams}</span>
<div title={creationFunctionParams} style={{ display: 'flex', alignItems: 'center', color: '#fc9', fontSize: 14 }}>
<i className="fas fa-sliders-h" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{creationFunctionParams}</span>
</div>
)}
{/* COMPLETION */}
{/* COMPLETION: Tool call summary if present */}
{completionChatMsg && typeof completionChatMsg === 'object' && completionChatMsg.toolCallSummary && (
<div className="observable-summary-item tool-call" title={completionChatMsg.toolCallSummary}>
<i className="fas fa-tools" />
<span>{completionChatMsg.toolCallSummary}</span>
<div
title={completionChatMsg.toolCallSummary}
style={{
display: 'flex',
alignItems: 'center',
color: '#ffd966', // Distinct color for tool calls
fontSize: 14,
marginTop: 2,
whiteSpace: 'pre-line',
wordBreak: 'break-all',
}}
>
<i className="fas fa-tools" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ whiteSpace: 'pre-line', display: 'block' }}>{completionChatMsg.toolCallSummary}</span>
</div>
)}
{completionChatMsg && (typeof completionChatMsg === 'string' || completionChatMsg.message) && (
<div className="observable-summary-item completion" title={typeof completionChatMsg === 'string' ? completionChatMsg : completionChatMsg.message}>
<i className="fas fa-robot" />
<span>{typeof completionChatMsg === 'string' ? completionChatMsg : completionChatMsg.message}</span>
{/* COMPLETION: Message content if present */}
{completionChatMsg && ((typeof completionChatMsg === 'object' && completionChatMsg.message) || typeof completionChatMsg === 'string') && (
<div
title={typeof completionChatMsg === 'object' ? completionChatMsg.message : completionChatMsg}
style={{
display: 'flex',
alignItems: 'center',
color: '#8fc7ff',
fontSize: 14,
marginTop: 2,
}}
>
<i className="fas fa-robot" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{typeof completionChatMsg === 'object' ? completionChatMsg.message : completionChatMsg}</span>
</div>
)}
{completionActionResult && (
<div className="observable-summary-item tool-call" title={completionActionResult}>
<i className="fas fa-bolt" />
<span>{completionActionResult}</span>
<div title={completionActionResult} style={{ display: 'flex', alignItems: 'center', color: '#ffd700', fontSize: 14 }}>
<i className="fas fa-bolt" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionActionResult}</span>
</div>
)}
{completionAgentState && (
<div className="observable-summary-item completion" title={completionAgentState}>
<i className="fas fa-brain" />
<span>{completionAgentState}</span>
<div title={completionAgentState} style={{ display: 'flex', alignItems: 'center', color: '#ffb8b8', fontSize: 14 }}>
<i className="fas fa-brain" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionAgentState}</span>
</div>
)}
{completionError && (
<div className="observable-summary-item error" title={completionError}>
<i className="fas fa-exclamation-triangle" />
<span>{completionError}</span>
<div title={completionError} style={{ display: 'flex', alignItems: 'center', color: '#f66', fontSize: 14 }}>
<i className="fas fa-exclamation-triangle" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionError}</span>
</div>
)}
{completionFilter && (
<div className="observable-summary-item completion" title={completionFilter}>
<i className="fas fa-shield-alt" />
<span>{completionFilter}</span>
<div title={completionFilter} style={{ display: 'flex', alignItems: 'center', color: '#ffd7', fontSize: 14 }}>
<i className="fas fa-shield-alt" style={{ marginRight: 6, flex: '0 0 auto' }}></i>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{completionFilter}</span>
</div>
)}
</div>
);
}
function ObservableCard({ observable, isNested = false }) {
const [isExpanded, setIsExpanded] = useState(false);
const [expandedChildren, setExpandedChildren] = useState(new Map());
const childKey = isNested ? `child-${observable.id}` : observable.id;
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
const toggleChild = (childId) => {
setExpandedChildren(prev => {
const newMap = new Map(prev);
newMap.set(childId, !prev.get(childId));
return newMap;
});
};
const isComplete = !!observable.completion;
const hasChildren = observable.children && observable.children.length > 0;
return (
<div className={`observable-card ${isNested ? 'nested' : ''}`}>
<div className="observable-header" onClick={toggleExpand}>
<div className="observable-title">
<div className="observable-icon">
<i className={`fas fa-${observable.icon || 'robot'}`} />
</div>
<div className="observable-info">
<div className="observable-name">
{observable.name}
<span className="observable-id">#{observable.id}</span>
</div>
<ObservableSummary observable={observable} />
</div>
</div>
<div className="observable-actions">
{!isComplete && <div className="spinner" />}
<i className={`fas fa-chevron-down observable-toggle ${isExpanded ? 'expanded' : ''}`} />
</div>
</div>
{isExpanded && (
<div className="observable-content">
{hasChildren && (
<div className="observable-children">
<h4 style={{ marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.9rem' }}>
Nested Observables
</h4>
{observable.children.map(child => (
<div key={child.id} className="observable-card nested">
<div className="observable-header" onClick={() => toggleChild(child.id)}>
<div className="observable-title">
<div className="observable-icon">
<i className={`fas fa-${child.icon || 'robot'}`} />
</div>
<div className="observable-info">
<div className="observable-name">
{child.name}
<span className="observable-id">#{child.id}</span>
</div>
<ObservableSummary observable={child} />
</div>
</div>
<div className="observable-actions">
{!child.completion && <div className="spinner" />}
<i className={`fas fa-chevron-down observable-toggle ${expandedChildren.get(child.id) ? 'expanded' : ''}`} />
</div>
</div>
{expandedChildren.get(child.id) && (
<div className="observable-content">
<CollapsibleRawSections container={child} />
</div>
)}
</div>
))}
</div>
)}
<CollapsibleRawSections container={observable} />
</div>
)}
</div>
);
}
import { useParams, Link } from 'react-router-dom';
import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import 'highlight.js/styles/monokai.css';
hljs.registerLanguage('json', json);
function AgentStatus() {
const { name } = useParams();
const [showStatus, setShowStatus] = useState(false);
const { name } = useParams();
const [statusData, setStatusData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [_eventSource, setEventSource] = useState(null);
// Store all observables by id
const [observableMap, setObservableMap] = useState({});
const [observableTree, setObservableTree] = useState([]);
const [expandedCards, setExpandedCards] = useState(new Map());
const [clearLoading, setClearLoading] = useState(false);
// Update document title
useEffect(() => {
if (name) {
document.title = `${name} - Status - LocalAGI`;
document.title = `Agent Status: ${name} - LocalAGI`;
}
return () => {
document.title = 'LocalAGI';
document.title = 'LocalAGI'; // Reset title when component unmounts
};
}, [name]);
// Fetch initial status data and setup SSE
// Fetch initial status data
useEffect(() => {
const fetchStatusData = async () => {
try {
@@ -296,6 +248,7 @@ function AgentStatus() {
fetchStatusData();
// Helper to build observable tree from map
function buildObservableTree(map) {
const nodes = Object.values(map);
const nodeMap = {};
@@ -311,6 +264,7 @@ function AgentStatus() {
return roots;
}
// Fetch initial observable history
const fetchObservables = async () => {
try {
const response = await fetch(`/api/agent/${name}/observables`);
@@ -318,7 +272,9 @@ function AgentStatus() {
const data = await response.json();
if (Array.isArray(data.History)) {
const map = {};
data.History.forEach(obs => { map[obs.id] = obs; });
data.History.forEach(obs => {
map[obs.id] = obs;
});
setObservableMap(map);
setObservableTree(buildObservableTree(map));
}
@@ -328,35 +284,49 @@ function AgentStatus() {
};
fetchObservables();
// Setup SSE connection
// Setup SSE connection for live updates
const sse = new EventSource(`/sse/${name}`);
setEventSource(sse);
sse.addEventListener('observable_update', (event) => {
const data = JSON.parse(event.data);
setObservableMap(prevMap => {
const prev = prevMap[data.id] || {};
const updated = { ...data, ...prev };
if (data.creation) updated.creation = data.creation;
if (data.completion) updated.completion = data.completion;
const updated = {
...data,
...prev,
};
// Events can be received out of order
if (data.creation)
updated.creation = data.creation;
if (data.completion)
updated.completion = data.completion;
if ((data.progress?.length ?? 0) > (prev.progress?.length ?? 0))
updated.progress = data.progress;
if (data.parent_id && !prevMap[data.parent_id])
prevMap[data.parent_id] = { id: data.parent_id, name: "unknown" };
prevMap[data.parent_id] = {
id: data.parent_id,
name: "unknown",
};
const newMap = { ...prevMap, [data.id]: updated };
setObservableTree(buildObservableTree(newMap));
return newMap;
});
});
// Listen for status events and append to statusData.History
sse.addEventListener('status', (event) => {
const status = event.data;
setStatusData(prev => {
// If prev is null, start a new object
if (!prev || typeof prev !== 'object') {
return { History: [status] };
}
// If History not present, add it
if (!Array.isArray(prev.History)) {
return { ...prev, History: [status] };
}
// Otherwise, append
return { ...prev, History: [...prev.History, status] };
});
});
@@ -365,8 +335,11 @@ function AgentStatus() {
console.error('SSE connection error:', err);
};
// Cleanup on unmount
return () => {
sse.close();
if (sse) {
sse.close();
}
};
}, [name]);
@@ -378,8 +351,10 @@ function AgentStatus() {
if (!resp.ok) {
console.error('Failed to clear observables, status:', resp.status);
} else {
// Clear local state immediately
setObservableMap({});
setObservableTree([]);
setExpandedCards(new Map());
}
} catch (e) {
console.error('Error clearing observables:', e);
@@ -388,124 +363,201 @@ function AgentStatus() {
}
};
// Helper function to safely convert any value to a displayable string
const formatValue = (value) => {
if (value === null || value === undefined) {
return 'N/A';
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch (err) {
return '[Complex Object]';
}
}
return String(value);
};
if (loading) {
return (
<div className="agent-status-container">
<div className="loading-container">
<div className="loader" />
<p>Loading agent status...</p>
</div>
<div>
<div></div>
<p>Loading agent status...</p>
</div>
);
}
if (error) {
return (
<div className="agent-status-container">
<div className="error-container">
<h2><i className="fas fa-exclamation-triangle" /> Error</h2>
<p>{error}</p>
<Link to="/agents" className="back-btn">
<i className="fas fa-arrow-left" /> Back to Agents
</Link>
</div>
<div>
<h2>Error</h2>
<p>{error}</p>
<Link to="/agents">
<i className="fas fa-arrow-left"></i> Back to Agents
</Link>
</div>
);
}
return (
<div className="agent-status-container">
{/* Page Header */}
<header className="page-header">
<div className="header-title-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<Link to="/agents" className="back-link" title="Back to agents">
<i className="fas fa-arrow-left" />
</Link>
<h1>{name}</h1>
</div>
<p className="agent-subtitle">Monitor agent activity and observables in real-time</p>
<div>
<h1>Agent Status: {name}</h1>
<div style={{ color: '#aaa', fontSize: 16, marginBottom: 18 }}>
See what the agent is doing and thinking
</div>
{error && (
<div>
{error}
</div>
</header>
{/* Current Status Section */}
)}
{loading && <div>Loading...</div>}
{statusData && (
<div className="status-section">
<div
className="status-section-header"
onClick={() => setShowStatus(!showStatus)}
>
<h2>
<i className="fas fa-chart-line" />
Current Status
</h2>
<i className={`fas fa-chevron-down status-section-toggle ${showStatus ? 'expanded' : ''}`} />
<div>
<div>
<div style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', userSelect: 'none' }}
onClick={() => setShowStatus(prev => !prev)}>
<h2 style={{ margin: 0 }}>Current Status</h2>
<i
className={`fas fa-chevron-${showStatus ? 'up' : 'down'}`}
style={{ color: 'var(--primary)', marginLeft: 12 }}
title={showStatus ? 'Collapse' : 'Expand'}
/>
</div>
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
Summary of the agent's thoughts and actions
</div>
{showStatus && (
<div style={{ marginTop: 10 }}>
{(Array.isArray(statusData?.History) && statusData.History.length === 0) && (
<div style={{ color: '#aaa' }}>No status history available.</div>
)}
{Array.isArray(statusData?.History) && statusData.History.map((item, idx) => (
<div key={idx} style={{
background: '#222',
border: '1px solid #444',
borderRadius: 8,
padding: '12px 16px',
marginBottom: 10,
whiteSpace: 'pre-line',
fontFamily: 'inherit',
fontSize: 15,
color: '#eee',
}}>
{/* Replace <br> tags with newlines, then render as pre-line */}
{typeof item === 'string'
? item.replace(/<br\s*\/?>/gi, '\n')
: JSON.stringify(item)}
</div>
))}
</div>
)}
</div>
<p className="status-section-description">
Real-time summary of the agent&apos;s thoughts and actions
</p>
{showStatus && (
<div style={{ marginTop: '1rem' }}>
{(Array.isArray(statusData?.History) && statusData.History.length === 0) && (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>
<i className="fas fa-inbox" style={{ fontSize: '2rem', marginBottom: '0.5rem', display: 'block' }} />
No status history available
</div>
)}
{Array.isArray(statusData?.History) && statusData.History.map((item, idx) => (
<div key={idx} className="card" style={{ marginBottom: '0.75rem' }}>
{typeof item === 'string'
? item.replace(/<br\s*\/?>/gi, '\n')
: JSON.stringify(item, null, 2)}
</div>
))}
{observableTree.length > 0 && (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>Observable Updates</h2>
<button
className="action-btn delete-btn"
onClick={handleClearObservables}
disabled={clearLoading}
title="Clear observable history"
>
{clearLoading ? 'Clearing…' : 'Clear history'}
</button>
</div>
<div style={{ color: '#aaa', fontSize: 14, margin: '5px 0 10px 2px' }}>
Drill down into what the agent is doing and thinking when activated by a connector
</div>
<div>
{observableTree.map((container, idx) => (
<div key={container.id || idx} className='card' style={{ marginBottom: '1em' }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }}
onClick={() => {
const newExpanded = !expandedCards.get(container.id);
setExpandedCards(new Map(expandedCards).set(container.id, newExpanded));
}}
>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', maxWidth: '90%' }}>
<i className={`fas fa-${container.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
<span style={{ width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<span>
<span className='stat-label'>{container.name}</span>#<span className='stat-label'>{container.id}</span>
</span>
<ObservableSummary observable={container} />
</div>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<i
className={`fas fa-chevron-${expandedCards.get(container.id) ? 'up' : 'down'}`}
style={{ color: 'var(--primary)' }}
title='Toggle details'
/>
{!container.completion && (
<div className='spinner' />
)}
</div>
</div>
<div style={{ display: expandedCards.get(container.id) ? 'block' : 'none' }}>
{container.children && container.children.length > 0 && (
<div style={{ marginLeft: '2em', marginTop: '1em' }}>
<h4>Nested Observables</h4>
{container.children.map(child => {
const childKey = `child-${child.id}`;
const isExpanded = expandedCards.get(childKey);
return (
<div key={`${container.id}-child-${child.id}`} className='card' style={{ background: '#222', marginBottom: '0.5em' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'hand', maxWidth: '100%' }}
onClick={() => {
const newExpanded = !expandedCards.get(childKey);
setExpandedCards(new Map(expandedCards).set(childKey, newExpanded));
}}
>
<div style={{ display: 'flex', maxWidth: '90%', gap: '10px', alignItems: 'center' }}>
<i className={`fas fa-${child.icon || 'robot'}`} style={{ verticalAlign: '-0.125em' }}></i>
<span style={{ width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<span>
<span className='stat-label'>{child.name}</span>#<span className='stat-label'>{child.id}</span>
</span>
<ObservableSummary observable={child} />
</div>
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<i
className={`fas fa-chevron-${isExpanded ? 'up' : 'down'}`}
style={{ color: 'var(--primary)' }}
title='Toggle details'
/>
{!child.completion && (
<div className='spinner' />
)}
</div>
</div>
<div style={{ display: isExpanded ? 'block' : 'none' }}>
<CollapsibleRawSections container={child} />
</div>
</div>
);
})}
</div>
)}
<CollapsibleRawSections container={container} />
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Observable Updates Section */}
{observableTree.length > 0 && (
<div className="status-section">
<div className="status-section-header">
<h2>
<i className="fas fa-eye" />
Observable Updates
</h2>
<button
className="action-btn delete-btn"
onClick={handleClearObservables}
disabled={clearLoading}
style={{ fontSize: '0.85rem', padding: '0.4rem 0.75rem' }}
>
{clearLoading ? (
<><i className="fas fa-spinner fa-spin" /> Clearing...</>
) : (
<><i className="fas fa-trash" /> Clear History</>
)}
</button>
</div>
<p className="status-section-description">
Drill down into agent activities triggered by connectors
</p>
<div style={{ marginTop: '1rem' }}>
{observableTree.map((observable) => (
<ObservableCard key={observable.id} observable={observable} />
))}
</div>
</div>
)}
{/* Empty State */}
{observableTree.length === 0 && statusData && (
<div className="status-section">
<div style={{ textAlign: 'center', padding: '3rem', color: 'var(--color-text-muted)' }}>
<i className="fas fa-satellite-dish" style={{ fontSize: '3rem', marginBottom: '1rem', display: 'block', opacity: 0.5 }} />
<h3 style={{ marginBottom: '0.5rem' }}>No Observables Yet</h3>
<p>Connectors will create observables when the agent is triggered.</p>
</div>
</div>
)}
</div>
);
}
+7 -50
View File
@@ -10,15 +10,12 @@ function Chat() {
const messagesEndRef = useRef(null);
// Use our custom chat hook
const {
messages,
sending,
error,
isConnected,
streamReasoning,
streamContent,
streamToolCalls,
sendMessage,
const {
messages,
sending,
error,
isConnected,
sendMessage,
clearChat,
clearError
} = useChat(name);
@@ -36,7 +33,7 @@ function Chat() {
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamContent, streamReasoning, streamToolCalls]);
}, [messages]);
// Show error toast if there's an error
useEffect(() => {
@@ -119,46 +116,6 @@ function Chat() {
</div>
))
)}
{sending && (streamReasoning || streamContent || streamToolCalls.length > 0) && (
<div className="message message-agent">
<div className="message-content">
{streamReasoning && (
<details open={!streamContent && streamToolCalls.length === 0} style={{ marginBottom: (streamContent || streamToolCalls.length > 0) ? '0.5rem' : 0 }}>
<summary style={{ cursor: 'pointer', fontStyle: 'italic', opacity: 0.7 }}>
{streamContent || streamToolCalls.length > 0 ? 'Thinking' : 'Thinking...'}
</summary>
<div
ref={(el) => { if (el) el.scrollTop = el.scrollHeight; }}
style={{ whiteSpace: 'pre-wrap', opacity: 0.6, fontSize: '0.9em', marginTop: '0.25rem', maxHeight: '300px', overflowY: 'auto' }}
>
{streamReasoning}
</div>
</details>
)}
{streamToolCalls.length > 0 ? (
<div style={{ marginTop: '0.25rem' }}>
{streamToolCalls.map((tc, idx) => (
<div key={idx} style={{ fontSize: '0.85em', opacity: 0.7, padding: '2px 0' }}>
<i className="fas fa-wrench" style={{ marginRight: '6px' }} />
<strong>{tc.name}</strong>
{tc.args && <span style={{ opacity: 0.5, marginLeft: '4px', fontSize: '0.9em' }}>{tc.args}</span>}
<span style={{ opacity: 0.5, marginLeft: '4px' }}>calling...</span>
</div>
))}
</div>
) : streamContent ? (
<div style={{ whiteSpace: 'pre-wrap' }}>{streamContent}</div>
) : null}
</div>
</div>
)}
{sending && !streamReasoning && !streamContent && streamToolCalls.length === 0 && (
<div className="message message-agent">
<div className="message-content" style={{ fontStyle: 'italic', opacity: 0.5 }}>
<i className="fas fa-spinner fa-spin" style={{ marginRight: '6px' }} /> Working...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
+6
View File
@@ -56,6 +56,12 @@ function Home() {
return (
<div>
<div className="image-container">
<img src="/app/logo_1.png" width="250" alt="LocalAGI Logo" />
</div>
{/*<h1 className="dashboard-title">LocalAGI</h1>*/}
{/* Dashboard Stats */}
<div className="dashboard-stats">
<div className="stat-item">
+49 -96
View File
@@ -1,84 +1,42 @@
import { useState, useEffect } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { agentApi } from '../utils/api';
import AgentForm from '../components/AgentForm';
function ImportAgent() {
const navigate = useNavigate();
const { showToast } = useOutletContext();
const [file, setFile] = useState(null);
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState(null);
const [formData, setFormData] = useState({});
const [showForm, setShowForm] = useState(false);
// Update document title
useEffect(() => {
document.title = 'Import Agent - LocalAGI';
return () => {
document.title = 'LocalAGI';
document.title = 'LocalAGI'; // Reset title when component unmounts
};
}, []);
// Fetch metadata on mount (needed for AgentForm)
useEffect(() => {
const fetchMetadata = async () => {
try {
const response = await agentApi.getAgentConfigMetadata();
if (response) {
setMetadata(response);
}
} catch (error) {
console.error('Error fetching metadata:', error);
}
};
fetchMetadata();
}, []);
const handleFileSelected = (file) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const parsed = JSON.parse(e.target.result);
setFormData(parsed);
setShowForm(true);
} catch (err) {
showToast('Failed to parse JSON file: ' + err.message, 'error');
}
};
reader.onerror = () => {
showToast('Failed to read file', 'error');
};
reader.readAsText(file);
};
const handleFileChange = (e) => {
handleFileSelected(e.target.files[0]);
const selectedFile = e.target.files[0];
if (selectedFile) {
setFile(selectedFile);
}
};
const handleDrop = (e) => {
e.preventDefault();
handleFileSelected(e.dataTransfer.files[0]);
};
const handleBack = () => {
setShowForm(false);
setFormData({});
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name || !formData.name.trim()) {
showToast('Agent name is required', 'error');
const handleImport = async () => {
if (!file) {
showToast('Please select a file to import', 'error');
return;
}
setLoading(true);
try {
await agentApi.createAgent(formData);
showToast(`Agent "${formData.name}" imported successfully`, 'success');
navigate(`/settings/${formData.name}`);
const formData = new FormData();
formData.append('file', file);
await agentApi.importAgent(formData);
showToast('Agent imported successfully', 'success');
navigate('/agents');
} catch (err) {
showToast(`Error importing agent: ${err.message}`, 'error');
} finally {
@@ -86,41 +44,6 @@ function ImportAgent() {
}
};
if (showForm) {
return (
<div className="create-agent-container">
<header className="page-header">
<h1>
<i className="fas fa-upload"></i> Import Agent
</h1>
</header>
<div className="create-agent-content">
<div className="section-box">
<div style={{ marginBottom: '1rem' }}>
<button className="action-btn" onClick={handleBack}>
<i className="fas fa-arrow-left"></i> Back to File Selection
</button>
</div>
<h2>
<i className="fas fa-robot"></i> Review & Edit Agent Configuration
</h2>
<AgentForm
formData={formData}
setFormData={setFormData}
onSubmit={handleSubmit}
loading={loading}
submitButtonText="Import Agent"
isEdit={false}
metadata={metadata}
/>
</div>
</div>
</div>
);
}
return (
<div className="import-agent-container">
<header className="page-header">
@@ -131,10 +54,17 @@ function ImportAgent() {
<div className="import-agent-content">
<div className="section-box">
<div className="file-dropzone" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
<div className="file-dropzone" onDrop={(e) => {
e.preventDefault();
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
setFile(droppedFile);
}
}}
onDragOver={(e) => e.preventDefault()}>
<div className="dropzone-content">
<i className="fas fa-cloud-upload-alt"></i>
<h2>Drop your agent JSON file here</h2>
<h2>Drop your agent file here</h2>
<p>or</p>
<label htmlFor="fileInput" className="action-btn">
<i className="fas fa-folder-open"></i> Select File
@@ -142,12 +72,35 @@ function ImportAgent() {
<input
type="file"
id="fileInput"
accept=".json"
accept=".json,.yaml,.yml"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
</div>
{file && (
<div className="selected-file-info">
<p>Selected file: {file.name}</p>
<button
className="import-button"
onClick={handleImport}
disabled={loading}
>
{loading ? (
<>
<i className="fas fa-spinner fa-spin"></i>
Importing...
</>
) : (
<>
<i className="fas fa-upload"></i>
Import Agent
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
-614
View File
@@ -1,614 +0,0 @@
import { useState, useEffect } from 'react';
import { useOutletContext } from 'react-router-dom';
import { collectionsApi } from '../utils/api';
const TABS = [
{ id: 'search', label: 'Search', icon: 'fa-search' },
{ id: 'collections', label: 'Collections', icon: 'fa-folder' },
{ id: 'upload', label: 'Upload', icon: 'fa-upload' },
{ id: 'sources', label: 'Sources', icon: 'fa-globe' },
{ id: 'entries', label: 'Entries', icon: 'fa-list' },
];
function Knowledge() {
const { showToast } = useOutletContext();
const [tab, setTab] = useState('search');
const [collections, setCollections] = useState([]);
const [loadingCollections, setLoadingCollections] = useState(true);
const fetchCollections = async () => {
setLoadingCollections(true);
try {
const list = await collectionsApi.list();
setCollections(Array.isArray(list) ? list : []);
} catch (err) {
showToast(err.message || 'Failed to load collections', 'error');
setCollections([]);
} finally {
setLoadingCollections(false);
}
};
useEffect(() => {
document.title = 'Knowledge base - LocalAGI';
fetchCollections();
}, []);
return (
<div className="page knowledge-page">
<header className="page-header">
<h1 className="page-title">
<i className="fas fa-database" />
Knowledge base
</h1>
<p className="page-description">
Manage collections, upload files, search content, and sync external sources.
</p>
</header>
<div className="knowledge-tabs">
{TABS.map((t) => (
<button
key={t.id}
type="button"
className={`tab-btn ${tab === t.id ? 'active' : ''}`}
onClick={() => setTab(t.id)}
>
<i className={`fas ${t.icon}`} />
<span>{t.label}</span>
</button>
))}
</div>
<div className="knowledge-content">
{tab === 'search' && (
<SearchTab
collections={collections}
loadingCollections={loadingCollections}
onRefreshCollections={fetchCollections}
showToast={showToast}
/>
)}
{tab === 'collections' && (
<CollectionsTab
collections={collections}
loadingCollections={loadingCollections}
onRefresh={fetchCollections}
showToast={showToast}
/>
)}
{tab === 'upload' && (
<UploadTab
collections={collections}
loadingCollections={loadingCollections}
onRefreshCollections={fetchCollections}
showToast={showToast}
/>
)}
{tab === 'sources' && (
<SourcesTab
collections={collections}
loadingCollections={loadingCollections}
showToast={showToast}
/>
)}
{tab === 'entries' && (
<EntriesTab
collections={collections}
loadingCollections={loadingCollections}
onRefreshCollections={fetchCollections}
showToast={showToast}
/>
)}
</div>
</div>
);
}
function SearchTab({ collections, loadingCollections, onRefreshCollections, showToast }) {
const [selectedCollection, setSelectedCollection] = useState('');
const [query, setQuery] = useState('');
const [maxResults, setMaxResults] = useState(5);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSearch = async () => {
if (!selectedCollection || !query.trim()) {
setError('Select a collection and enter a query');
return;
}
setError('');
setLoading(true);
try {
const list = await collectionsApi.search(selectedCollection, query.trim(), maxResults || 5);
setResults(Array.isArray(list) ? list : []);
if ((list?.length ?? 0) === 0) {
setResults([{ Content: `No results for "${query}"` }]);
}
} catch (err) {
showToast(err.message || 'Search failed', 'error');
setResults([]);
} finally {
setLoading(false);
}
};
return (
<section className="knowledge-card">
<h2 className="knowledge-card-title">Search collections</h2>
<p className="knowledge-card-desc">Semantic search over your indexed content.</p>
{error && <div className="knowledge-error">{error}</div>}
<div className="form-group">
<label>Collection</label>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
disabled={loadingCollections}
>
<option value="">Select a collection</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="form-group">
<label>Query</label>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Enter search query..."
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Max results</label>
<input
type="number"
min={1}
max={20}
value={maxResults}
onChange={(e) => setMaxResults(Number(e.target.value) || 5)}
/>
</div>
<button type="button" className="btn btn-primary" onClick={handleSearch} disabled={loading}>
{loading ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-search" />}
<span>{loading ? 'Searching...' : 'Search'}</span>
</button>
</div>
<div className="search-results">
<h3>Results</h3>
{results.length > 0 ? (
<ul className="results-list">
{results.map((r, i) => (
<li key={i} className="result-item">
<pre>{typeof r === 'object' && r.Content != null ? r.Content : JSON.stringify(r, null, 2)}</pre>
</li>
))}
</ul>
) : (
!loading && <p className="muted">Run a search to see results.</p>
)}
</div>
</section>
);
}
function CollectionsTab({ collections, loadingCollections, onRefresh, showToast }) {
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const [resetting, setResetting] = useState(null);
const handleCreate = async () => {
if (!newName.trim()) {
showToast('Enter a collection name', 'error');
return;
}
setCreating(true);
try {
await collectionsApi.create(newName.trim());
showToast(`Collection "${newName}" created`, 'success');
setNewName('');
onRefresh();
} catch (err) {
showToast(err.message || 'Failed to create collection', 'error');
} finally {
setCreating(false);
}
};
const handleReset = async (name) => {
if (!confirm(`Reset collection "${name}"? This removes all entries and cannot be undone.`)) return;
setResetting(name);
try {
await collectionsApi.reset(name);
showToast(`Collection "${name}" reset`, 'success');
onRefresh();
} catch (err) {
showToast(err.message || 'Failed to reset', 'error');
} finally {
setResetting(null);
}
};
return (
<section className="knowledge-card">
<h2 className="knowledge-card-title">Create collection</h2>
<div className="form-row">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Collection name..."
className="flex-1"
/>
<button type="button" className="btn btn-primary" onClick={handleCreate} disabled={creating}>
{creating ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-plus" />}
<span>{creating ? 'Creating...' : 'Create'}</span>
</button>
</div>
<div className="form-row" style={{ alignItems: 'center', marginBottom: '0.75rem' }}>
<h2 className="knowledge-card-title" style={{ margin: 0 }}>Your collections</h2>
<button type="button" className="btn btn-ghost icon-only" onClick={onRefresh} disabled={loadingCollections} title="Refresh">
<i className={loadingCollections ? 'fas fa-spinner fa-spin' : 'fas fa-sync-alt'} />
</button>
</div>
{loadingCollections ? (
<p className="muted">Loading...</p>
) : collections.length === 0 ? (
<p className="muted">No collections. Create one above.</p>
) : (
<ul className="knowledge-list">
{collections.map((c) => (
<li key={c} className="knowledge-list-item">
<i className="fas fa-folder" />
<span>{c}</span>
<button
type="button"
className="btn btn-ghost danger"
onClick={() => handleReset(c)}
disabled={resetting === c}
title="Reset collection"
>
{resetting === c ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-redo-alt" />}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function UploadTab({ collections, loadingCollections, onRefreshCollections, showToast }) {
const [selectedCollection, setSelectedCollection] = useState('');
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async () => {
if (!selectedCollection) {
showToast('Select a collection', 'error');
return;
}
if (!file) {
showToast('Select a file', 'error');
return;
}
setUploading(true);
try {
await collectionsApi.upload(selectedCollection, file);
showToast('File uploaded', 'success');
setFile(null);
onRefreshCollections();
} catch (err) {
showToast(err.message || 'Upload failed', 'error');
} finally {
setUploading(false);
}
};
return (
<section className="knowledge-card">
<h2 className="knowledge-card-title">Upload file</h2>
<div className="form-group">
<label>Collection</label>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
disabled={loadingCollections}
>
<option value="">Select a collection</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="form-group">
<label>File</label>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file && <span className="muted">{file.name}</span>}
</div>
<button type="button" className="btn btn-primary" onClick={handleUpload} disabled={uploading}>
{uploading ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-upload" />}
<span>{uploading ? 'Uploading...' : 'Upload'}</span>
</button>
</section>
);
}
function SourcesTab({ collections, loadingCollections, showToast }) {
const [selectedCollection, setSelectedCollection] = useState('');
const [url, setUrl] = useState('');
const [intervalMin, setIntervalMin] = useState(60);
const [sources, setSources] = useState([]);
const [loadingSources, setLoadingSources] = useState(false);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState(null);
useEffect(() => {
if (!selectedCollection) {
setSources([]);
return;
}
let cancelled = false;
setLoadingSources(true);
collectionsApi.listSources(selectedCollection)
.then((list) => { if (!cancelled) setSources(Array.isArray(list) ? list : []); })
.catch((err) => { if (!cancelled) showToast(err.message || 'Failed to load sources', 'error'); })
.finally(() => { if (!cancelled) setLoadingSources(false); });
return () => { cancelled = true; };
}, [selectedCollection, showToast]);
const handleAdd = async () => {
if (!selectedCollection || !url.trim()) {
showToast('Select a collection and enter a URL', 'error');
return;
}
setAdding(true);
try {
await collectionsApi.addSource(selectedCollection, url.trim(), intervalMin || 60);
showToast('Source added', 'success');
setUrl('');
setSources(await collectionsApi.listSources(selectedCollection));
} catch (err) {
showToast(err.message || 'Failed to add source', 'error');
} finally {
setAdding(false);
}
};
const handleRemove = async (sourceUrl) => {
if (!selectedCollection) return;
setRemoving(sourceUrl);
try {
await collectionsApi.removeSource(selectedCollection, sourceUrl);
showToast('Source removed', 'success');
setSources(await collectionsApi.listSources(selectedCollection));
} catch (err) {
showToast(err.message || 'Failed to remove source', 'error');
} finally {
setRemoving(null);
}
};
return (
<section className="knowledge-card">
<h2 className="knowledge-card-title">External sources</h2>
<p className="knowledge-card-desc">Sync URLs to a collection periodically.</p>
<div className="form-group">
<label>Collection</label>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
disabled={loadingCollections}
>
<option value="">Select a collection</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="form-row">
<div className="form-group flex-1">
<label>URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
/>
</div>
<div className="form-group">
<label>Interval (min)</label>
<input
type="number"
min={1}
value={intervalMin}
onChange={(e) => setIntervalMin(Number(e.target.value) || 60)}
/>
</div>
</div>
<button type="button" className="btn btn-primary" onClick={handleAdd} disabled={adding}>
{adding ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-plus" />}
<span>{adding ? 'Adding...' : 'Add source'}</span>
</button>
<h3 className="knowledge-card-title">Registered sources</h3>
{loadingSources ? (
<p className="muted">Loading...</p>
) : sources.length === 0 ? (
<p className="muted">No sources. Add one above.</p>
) : (
<ul className="knowledge-list">
{sources.map((s) => (
<li key={s.url} className="knowledge-list-item">
<i className="fas fa-globe" />
<div>
<span>{s.url}</span>
<span className="muted">Every {s.update_interval ?? 60} min</span>
</div>
<button
type="button"
className="btn btn-ghost danger"
onClick={() => handleRemove(s.url)}
disabled={removing === s.url}
title="Remove"
>
{removing === s.url ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-trash" />}
</button>
</li>
))}
</ul>
)}
</section>
);
}
function EntriesTab({ collections, loadingCollections, onRefreshCollections, showToast }) {
const [selectedCollection, setSelectedCollection] = useState('');
const [entries, setEntries] = useState([]);
const [loadingEntries, setLoadingEntries] = useState(false);
const [deleting, setDeleting] = useState(null);
const [resetting, setResetting] = useState(null);
const [viewContent, setViewContent] = useState(null);
const [loadingContent, setLoadingContent] = useState(null);
useEffect(() => {
if (!selectedCollection) {
setEntries([]);
return;
}
let cancelled = false;
setLoadingEntries(true);
collectionsApi.listEntries(selectedCollection)
.then((list) => { if (!cancelled) setEntries(Array.isArray(list) ? list : []); })
.catch((err) => { if (!cancelled) showToast(err.message || 'Failed to load entries', 'error'); })
.finally(() => { if (!cancelled) setLoadingEntries(false); });
return () => { cancelled = true; };
}, [selectedCollection, showToast]);
const handleDelete = async (entry) => {
if (!selectedCollection) return;
if (!confirm(`Delete "${entry}" from collection?`)) return;
setDeleting(entry);
try {
await collectionsApi.deleteEntry(selectedCollection, entry);
showToast('Entry deleted', 'success');
setEntries(await collectionsApi.listEntries(selectedCollection));
} catch (err) {
showToast(err.message || 'Failed to delete', 'error');
} finally {
setDeleting(null);
}
};
const handleReset = async () => {
if (!selectedCollection) return;
if (!confirm(`Reset collection "${selectedCollection}"? This removes all entries.`)) return;
setResetting(selectedCollection);
try {
await collectionsApi.reset(selectedCollection);
showToast('Collection reset', 'success');
setEntries([]);
onRefreshCollections();
} catch (err) {
showToast(err.message || 'Failed to reset', 'error');
} finally {
setResetting(null);
}
};
const handleViewContent = async (entry) => {
if (!selectedCollection) return;
setLoadingContent(entry);
try {
const { content, chunkCount } = await collectionsApi.getEntryContent(selectedCollection, entry);
setViewContent({ entry, content, chunkCount });
} catch (err) {
showToast(err.message || 'Failed to load content', 'error');
} finally {
setLoadingContent(null);
}
};
return (
<section className="knowledge-card">
<h2 className="knowledge-card-title">Collection entries</h2>
<div className="form-group">
<label>Collection</label>
<select
value={selectedCollection}
onChange={(e) => setSelectedCollection(e.target.value)}
disabled={loadingCollections}
>
<option value="">Select a collection</option>
{collections.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<div className="form-row">
<button
type="button"
className="btn btn-ghost danger"
onClick={handleReset}
disabled={!selectedCollection || resetting === selectedCollection}
>
{resetting === selectedCollection ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-redo-alt" />}
<span>Reset collection</span>
</button>
</div>
{loadingEntries ? (
<p className="muted">Loading entries...</p>
) : entries.length === 0 ? (
<p className="muted">Select a collection to view entries.</p>
) : (
<ul className="knowledge-list">
{entries.map((entry) => (
<li key={entry} className="knowledge-list-item">
<i className="fas fa-file-alt" />
<span className="truncate">{entry}</span>
<div className="btn-group">
<button
type="button"
className="btn btn-ghost"
onClick={() => handleViewContent(entry)}
disabled={loadingContent === entry}
title="View content"
>
{loadingContent === entry ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-eye" />}
</button>
<button
type="button"
className="btn btn-ghost danger"
onClick={() => handleDelete(entry)}
disabled={deleting === entry}
title="Delete"
>
{deleting === entry ? <i className="fas fa-spinner fa-spin" /> : <i className="fas fa-trash" />}
</button>
</div>
</li>
))}
</ul>
)}
{viewContent && (
<div className="modal-overlay" onClick={() => setViewContent(null)}>
<div className="modal-content knowledge-modal" onClick={(e) => e.stopPropagation()}>
<h3>Content: {viewContent.entry}</h3>
<p className="muted">{viewContent.chunkCount} chunk(s)</p>
<pre className="knowledge-entry-content">{viewContent.content || '(empty)'}</pre>
<button type="button" className="btn btn-primary" onClick={() => setViewContent(null)}>Close</button>
</div>
</div>
)}
</section>
);
}
export default Knowledge;
-461
View File
@@ -1,461 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate, useLocation, Link, useOutletContext } from 'react-router-dom';
import { skillsApi } from '../utils/api';
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/'];
function isValidResourcePath(path) {
return RESOURCE_PREFIXES.some((p) => path.startsWith(p)) && !path.includes('..');
}
function ResourceGroup({ title, icon, items, readOnly, pathPrefix, onView, onDelete, onUpload }) {
return (
<div className="resource-section" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 className="section-title" style={{ marginBottom: 0 }}>
<i className={`fas fa-${icon}`} /> {title}
</h3>
{!readOnly && (
<button type="button" className="action-btn success" onClick={() => onUpload(pathPrefix)}>
<i className="fas fa-upload" /> Upload
</button>
)}
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{items.length === 0 ? (
<li style={{ color: 'var(--color-text-secondary)', padding: '0.75rem', fontSize: '0.9rem' }}>No {title.toLowerCase()} yet.</li>
) : (
items.map((res) => (
<li key={res.path} className="card" style={{ padding: '0.5rem 0.75rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ minWidth: 0 }}>
<span style={{ fontWeight: 500 }}>{res.name}</span>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '0.85rem', marginLeft: '0.5rem' }}>{res.mime_type} · {(res.size || 0).toLocaleString()} B</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="action-btn" onClick={() => onView(res)}>
<i className="fas fa-edit" /> View/Edit
</button>
{!readOnly && (
<button type="button" className="action-btn delete-btn" onClick={() => onDelete(res.path)}>
<i className="fas fa-trash" /> Delete
</button>
)}
</div>
</li>
))
)}
</ul>
</div>
);
}
function ResourcesSection({ skillName, showToast }) {
const [data, setData] = useState({ scripts: [], references: [], assets: [], readOnly: false });
const [loading, setLoading] = useState(true);
const [editor, setEditor] = useState({ open: false, path: '', name: '', content: '', readable: true, saving: false });
const [upload, setUpload] = useState({ open: false, pathPrefix: 'assets/', file: null, pathInput: '', uploading: false });
const [deletePath, setDeletePath] = useState(null);
const load = async () => {
setLoading(true);
try {
const res = await skillsApi.listResources(skillName);
setData({
scripts: res.scripts || [],
references: res.references || [],
assets: res.assets || [],
readOnly: res.readOnly === true,
});
} catch (err) {
showToast(err.message || 'Failed to load resources', 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, [skillName]);
const handleView = async (res) => {
setEditor({ open: true, path: res.path, name: res.name, content: '', readable: res.readable !== false, saving: false });
if (res.readable !== false) {
try {
const json = await skillsApi.getResource(skillName, res.path, { json: true });
const content = json.encoding === 'base64' && json.content ? atob(json.content) : (json.content || '');
setEditor((e) => ({ ...e, content }));
} catch (err) {
showToast(err.message || 'Failed to load file', 'error');
}
}
};
const handleEditorSave = async () => {
setEditor((e) => ({ ...e, saving: true }));
try {
await skillsApi.updateResource(skillName, editor.path, editor.content);
showToast('Resource updated', 'success');
setEditor((e) => ({ ...e, open: false }));
load();
} catch (err) {
showToast(err.message || 'Update failed', 'error');
} finally {
setEditor((e) => ({ ...e, saving: false }));
}
};
const handleUploadOpen = (pathPrefix) => {
setUpload({ open: true, pathPrefix, file: null, pathInput: '', uploading: false });
};
const handleUploadSubmit = async () => {
const path = upload.pathInput.trim() || (upload.file ? upload.pathPrefix + upload.file.name : '');
if (!path || !upload.file) {
showToast('Select a file and ensure path is set', 'error');
return;
}
if (!isValidResourcePath(path)) {
showToast('Path must start with scripts/, references/, or assets/', 'error');
return;
}
setUpload((u) => ({ ...u, uploading: true }));
try {
await skillsApi.createResource(skillName, path, upload.file);
showToast('Resource added', 'success');
setUpload((u) => ({ ...u, open: false }));
load();
} catch (err) {
showToast(err.message || 'Upload failed', 'error');
} finally {
setUpload((u) => ({ ...u, uploading: false }));
}
};
const handleDeleteConfirm = async () => {
if (!deletePath) return;
try {
await skillsApi.deleteResource(skillName, deletePath);
showToast('Resource deleted', 'success');
setDeletePath(null);
load();
} catch (err) {
showToast(err.message || 'Delete failed', 'error');
}
};
return (
<>
<div className="section-box">
<h2><i className="fas fa-folder" /> Resources</h2>
<p className="page-description" style={{ marginBottom: '1rem' }}>Scripts, references, and assets for this skill. Paths must start with scripts/, references/, or assets/.</p>
{loading ? (
<p>Loading resources...</p>
) : (
<>
<ResourceGroup title="Scripts" icon="code" pathPrefix="scripts/" items={data.scripts} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
<ResourceGroup title="References" icon="book" pathPrefix="references/" items={data.references} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
<ResourceGroup title="Assets" icon="image" pathPrefix="assets/" items={data.assets} readOnly={data.readOnly} onView={handleView} onDelete={setDeletePath} onUpload={handleUploadOpen} />
</>
)}
</div>
{editor.open && (
<div className="modal-overlay" style={{ position: 'fixed', inset: 0, background: 'var(--color-bg-overlay)', zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={() => !editor.saving && setEditor((e) => ({ ...e, open: false }))}>
<div className="card" style={{ maxWidth: '700px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
<h3 className="section-title" style={{ marginTop: 0 }}>Edit {editor.name}</h3>
{editor.readable ? (
<>
<textarea className="input" value={editor.content} onChange={(e) => setEditor((x) => ({ ...x, content: e.target.value }))} rows={14} style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.9rem', marginBottom: '1rem' }} />
<div className="form-actions" style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
<button type="button" className="action-btn" onClick={() => setEditor((e) => ({ ...e, open: false }))}>Cancel</button>
<button type="button" className="action-btn success" disabled={editor.saving} onClick={handleEditorSave}>{editor.saving ? 'Saving...' : 'Save'}</button>
</div>
</>
) : (
<p style={{ color: 'var(--color-text-secondary)' }}>Binary file. Download via API or export skill.</p>
)}
</div>
</div>
)}
{upload.open && (
<div className="modal-overlay" style={{ position: 'fixed', inset: 0, background: 'var(--color-bg-overlay)', zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={() => !upload.uploading && setUpload((u) => ({ ...u, open: false }))}>
<div className="card" style={{ maxWidth: '400px', width: '100%' }} onClick={(e) => e.stopPropagation()}>
<h3 className="section-title" style={{ marginTop: 0 }}>Upload to {upload.pathPrefix}</h3>
<div className="form-group">
<label>File</label>
<input type="file" className="input" onChange={(e) => setUpload((u) => ({ ...u, file: e.target.files?.[0] || null }))} />
</div>
<div className="form-group">
<label>Path (default: {upload.pathPrefix} + filename)</label>
<input type="text" className="input" placeholder={`${upload.pathPrefix}filename`} value={upload.pathInput} onChange={(e) => setUpload((u) => ({ ...u, pathInput: e.target.value }))} />
</div>
<div className="form-actions" style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
<button type="button" className="action-btn" onClick={() => setUpload((u) => ({ ...u, open: false }))}>Cancel</button>
<button type="button" className="action-btn success" disabled={upload.uploading || !upload.file} onClick={handleUploadSubmit}>{upload.uploading ? 'Uploading...' : 'Upload'}</button>
</div>
</div>
</div>
)}
{deletePath && (
<div className="modal-overlay" style={{ position: 'fixed', inset: 0, background: 'var(--color-bg-overlay)', zIndex: 50, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }} onClick={() => setDeletePath(null)}>
<div className="card" style={{ maxWidth: '360px' }} onClick={(e) => e.stopPropagation()}>
<p>Delete resource <strong>{deletePath}</strong>?</p>
<div className="form-actions" style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
<button type="button" className="action-btn" onClick={() => setDeletePath(null)}>Cancel</button>
<button type="button" className="action-btn delete-btn" onClick={handleDeleteConfirm}>Delete</button>
</div>
</div>
</div>
)}
</>
);
}
function SkillEdit() {
const { name: nameParam } = useParams();
const location = useLocation();
const isNew = location.pathname.endsWith('/new');
const name = nameParam ? decodeURIComponent(nameParam) : undefined;
const navigate = useNavigate();
const { showToast } = useOutletContext();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [activeSection, setActiveSection] = useState('basic-section');
const [form, setForm] = useState({
name: '',
description: '',
content: '',
license: '',
compatibility: '',
metadata: {},
allowedTools: '',
});
useEffect(() => {
document.title = isNew ? 'New skill - LocalAGI' : `Edit ${name} - LocalAGI`;
if (isNew) {
setLoading(false);
return;
}
if (name) {
skillsApi.get(name)
.then((data) => {
setForm({
name: data.name || '',
description: data.description || '',
content: data.content || '',
license: data.license || '',
compatibility: data.compatibility || '',
metadata: data.metadata || {},
allowedTools: data['allowed-tools'] || '',
});
})
.catch((err) => {
showToast(err.message || 'Failed to load skill', 'error');
navigate('/skills');
})
.finally(() => setLoading(false));
}
}, [isNew, name, navigate, showToast]);
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const payload = {
name: form.name,
description: form.description,
content: form.content,
license: form.license || undefined,
compatibility: form.compatibility || undefined,
metadata: Object.keys(form.metadata).length ? form.metadata : undefined,
'allowed-tools': form.allowedTools || undefined,
};
if (isNew) {
await skillsApi.create(payload);
showToast('Skill created', 'success');
} else {
await skillsApi.update(name, { ...payload, name: undefined });
showToast('Skill updated', 'success');
}
navigate('/skills');
} catch (err) {
showToast(err.message || 'Save failed', 'error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="create-agent-container">
<div className="loading" style={{ padding: '2rem', textAlign: 'center' }}>
<div className="loader" />
<p>Loading skill...</p>
</div>
</div>
);
}
return (
<div className="create-agent-container">
<header className="page-header">
<div>
<Link to="/skills" className="back-link" style={{ marginBottom: '0.5rem', display: 'inline-block' }}>
<i className="fas fa-arrow-left" /> Back to skills
</Link>
<h1>
<i className="fas fa-book" /> {isNew ? 'New skill' : `Edit: ${name}`}
</h1>
</div>
</header>
<div className="create-agent-content">
<div className="section-box">
<h2>
<i className="fas fa-cog" /> Skill configuration
</h2>
<div className="agent-form-container">
<div className="wizard-sidebar">
<ul className="wizard-nav">
<li
className={`wizard-nav-item ${activeSection === 'basic-section' ? 'active' : ''}`}
onClick={() => setActiveSection('basic-section')}
>
<i className="fas fa-info-circle" /> Basic information
</li>
<li
className={`wizard-nav-item ${activeSection === 'content-section' ? 'active' : ''}`}
onClick={() => setActiveSection('content-section')}
>
<i className="fas fa-file-alt" /> Content
</li>
<li
className={`wizard-nav-item ${activeSection === 'resources-section' ? 'active' : ''}`}
onClick={() => setActiveSection('resources-section')}
>
<i className="fas fa-folder" /> Resources
</li>
</ul>
</div>
<div className="form-content-area">
<form className="agent-form" onSubmit={handleSubmit} noValidate>
<div style={{ display: activeSection === 'basic-section' ? 'block' : 'none' }}>
<h3 className="section-title">Basic information</h3>
<div className="mb-4">
<label htmlFor="skill-name">Name (lowercase, hyphens only) <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input
id="skill-name"
name="name"
type="text"
className="input"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
disabled={!isNew}
placeholder="my-skill"
/>
{!isNew && <p className="help-text" style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--color-text-secondary)' }}>Name cannot be changed after creation.</p>}
</div>
<div className="mb-4">
<label htmlFor="skill-desc">Description (required, 11024 chars) <span style={{ color: 'var(--color-error)' }}>*</span></label>
<textarea
id="skill-desc"
name="description"
className="input"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
maxLength={1024}
rows={2}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-license">License (optional)</label>
<input
id="skill-license"
name="license"
type="text"
className="input"
value={form.license}
onChange={(e) => setForm((f) => ({ ...f, license: e.target.value }))}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-compat">Compatibility (optional, max 500 chars)</label>
<input
id="skill-compat"
name="compatibility"
type="text"
className="input"
value={form.compatibility}
onChange={(e) => setForm((f) => ({ ...f, compatibility: e.target.value }))}
maxLength={500}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-allowed-tools">Allowed tools (optional)</label>
<input
id="skill-allowed-tools"
name="allowedTools"
type="text"
className="input"
value={form.allowedTools}
onChange={(e) => setForm((f) => ({ ...f, allowedTools: e.target.value }))}
placeholder="tool1, tool2"
/>
</div>
</div>
<div style={{ display: activeSection === 'content-section' ? 'block' : 'none' }}>
<h3 className="section-title">Content</h3>
<div className="mb-4">
<label htmlFor="skill-content">Skill content (markdown)</label>
<textarea
id="skill-content"
name="content"
className="input"
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
rows={14}
style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}
/>
</div>
</div>
{activeSection === 'resources-section' && (
<div style={{ display: 'block' }}>
{isNew || !name ? (
<div className="section-box" style={{ padding: '1.5rem', marginTop: 0 }}>
<h3 className="section-title">Resources</h3>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 0 }}>
Save the skill first to add scripts, references, and assets. After creating the skill, use this tab to upload files and manage resources.
</p>
</div>
) : (
<ResourcesSection skillName={name} showToast={showToast} />
)}
</div>
)}
<div className="form-actions" style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid var(--color-border)' }}>
<Link to="/skills" className="action-btn">
<i className="fas fa-times" /> Cancel
</Link>
<button type="submit" className="action-btn success" disabled={saving}>
<i className="fas fa-save" /> {saving ? 'Saving...' : (isNew ? 'Create skill' : 'Save changes')}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default SkillEdit;
-310
View File
@@ -1,310 +0,0 @@
import { useState, useEffect } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { skillsApi } from '../utils/api';
function Skills() {
const [skills, setSkills] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [importing, setImporting] = useState(false);
const [unavailable, setUnavailable] = useState(false);
const [showGitRepos, setShowGitRepos] = useState(false);
const [gitRepos, setGitRepos] = useState([]);
const [gitRepoUrl, setGitRepoUrl] = useState('');
const [gitReposLoading, setGitReposLoading] = useState(false);
const [gitReposAction, setGitReposAction] = useState(null);
const { showToast } = useOutletContext();
const fetchSkills = async () => {
setLoading(true);
setUnavailable(false);
const timeoutMs = 15000;
const withTimeout = (p) =>
Promise.race([
p,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
),
]);
try {
if (searchQuery.trim()) {
const data = await withTimeout(skillsApi.search(searchQuery.trim()));
setSkills(Array.isArray(data) ? data : []);
} else {
const data = await withTimeout(skillsApi.list());
setSkills(Array.isArray(data) ? data : []);
}
} catch (err) {
if (err.message?.includes('503') || err.message?.includes('skills')) {
setUnavailable(true);
setSkills([]);
} else {
showToast(err.message || 'Failed to load skills', 'error');
setSkills([]);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
document.title = 'Skills - LocalAGI';
}, []);
useEffect(() => {
fetchSkills();
}, [searchQuery]);
const deleteSkill = async (name) => {
if (!confirm(`Delete skill "${name}"?`)) return;
try {
await skillsApi.delete(name);
showToast('Skill deleted', 'success');
fetchSkills();
} catch (err) {
showToast(err.message || 'Failed to delete skill', 'error');
}
};
const exportSkill = async (name) => {
try {
const url = skillsApi.exportUrl(name);
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(res.statusText || 'Export failed');
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${name.replace(/\//g, '-')}.tar.gz`;
a.click();
URL.revokeObjectURL(a.href);
showToast('Export started', 'success');
} catch (err) {
showToast(err.message || 'Export failed', 'error');
}
};
const handleImport = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setImporting(true);
try {
await skillsApi.import(file);
showToast('Skill imported', 'success');
fetchSkills();
} catch (err) {
showToast(err.message || 'Import failed', 'error');
} finally {
setImporting(false);
e.target.value = '';
}
};
const loadGitRepos = async () => {
setGitReposLoading(true);
try {
const list = await skillsApi.listGitRepos();
setGitRepos(Array.isArray(list) ? list : []);
} catch (err) {
showToast(err.message || 'Failed to load Git repos', 'error');
setGitRepos([]);
} finally {
setGitReposLoading(false);
}
};
useEffect(() => {
if (showGitRepos) loadGitRepos();
}, [showGitRepos]);
const addGitRepo = async (e) => {
e.preventDefault();
const url = gitRepoUrl.trim();
if (!url) return;
setGitReposAction('add');
try {
await skillsApi.addGitRepo(url);
setGitRepoUrl('');
await loadGitRepos();
fetchSkills();
showToast('Git repo added and syncing', 'success');
} catch (err) {
showToast(err.message || 'Failed to add repo', 'error');
} finally {
setGitReposAction(null);
}
};
const syncGitRepo = async (id) => {
setGitReposAction(id);
try {
await skillsApi.syncGitRepo(id);
await loadGitRepos();
fetchSkills();
showToast('Repo synced', 'success');
} catch (err) {
showToast(err.message || 'Sync failed', 'error');
} finally {
setGitReposAction(null);
}
};
const toggleGitRepo = async (id) => {
try {
await skillsApi.toggleGitRepo(id);
await loadGitRepos();
fetchSkills();
showToast('Repo toggled', 'success');
} catch (err) {
showToast(err.message || 'Toggle failed', 'error');
}
};
const deleteGitRepo = async (id) => {
if (!confirm('Remove this Git repository? Skills from it will no longer be available.')) return;
try {
await skillsApi.deleteGitRepo(id);
await loadGitRepos();
fetchSkills();
showToast('Repo removed', 'success');
} catch (err) {
showToast(err.message || 'Remove failed', 'error');
}
};
if (unavailable) {
return (
<div className="page skills-page">
<header className="page-header">
<h1>Skills</h1>
<p className="page-description">Skills service is not available or the index is rebuilding. Try again in a moment.</p>
<button type="button" className="btn btn-primary" onClick={() => { setUnavailable(false); fetchSkills(); }}>
Retry
</button>
</header>
</div>
);
}
return (
<div className="page skills-page">
<header className="page-header">
<div>
<h1>Skills</h1>
<p className="page-description">Manage agent skills (reusable instructions and resources). Skills are stored under the state directory. Create or import skills, and enable &quot;Enable Skills&quot; per agent to give them access.</p>
</div>
<div className="header-actions" style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
className="input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '220px' }}
/>
<Link to="/skills/new" className="action-btn success">
<i className="fas fa-plus" /> New skill
</Link>
<label className="action-btn" style={{ margin: 0, cursor: 'pointer' }}>
<input type="file" accept=".tar.gz" onChange={handleImport} disabled={importing} style={{ display: 'none' }} />
{importing ? 'Importing...' : <><i className="fas fa-file-import" /> Import</>}
</label>
<button type="button" className="action-btn" onClick={() => setShowGitRepos((v) => !v)}>
<i className="fas fa-code-branch" /> Git Repos
</button>
</div>
</header>
{showGitRepos && (
<div className="section-box" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
<i className="fas fa-code-branch" /> Git repositories
</h2>
<p className="page-description" style={{ marginBottom: '1rem' }}>
Add Git repositories to pull skills from. Skills will appear in the list after sync.
</p>
<form onSubmit={addGitRepo} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<input
type="url"
className="input"
placeholder="https://github.com/user/repo or git@github.com:user/repo.git"
value={gitRepoUrl}
onChange={(e) => setGitRepoUrl(e.target.value)}
style={{ flex: '1', minWidth: '200px' }}
/>
<button type="submit" className="action-btn success" disabled={gitReposAction === 'add'}>
{gitReposAction === 'add' ? 'Adding...' : 'Add repo'}
</button>
</form>
{gitReposLoading ? (
<p>Loading repos...</p>
) : gitRepos.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>No Git repos configured. Add one above.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{gitRepos.map((r) => (
<li key={r.id} className="card" style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>{r.name || r.url}</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginLeft: '0.5rem' }}>{r.url}</span>
{!r.enabled && <span className="badge" style={{ marginLeft: '0.5rem' }}>Disabled</span>}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="action-btn" onClick={() => syncGitRepo(r.id)} disabled={gitReposAction === r.id}>
{gitReposAction === r.id ? 'Syncing...' : <><i className="fas fa-sync-alt" /> Sync</>}
</button>
<button type="button" className="action-btn" onClick={() => toggleGitRepo(r.id)} title={r.enabled ? 'Disable' : 'Enable'}>
<i className={`fas fa-toggle-${r.enabled ? 'on' : 'off'}`} />
</button>
<button type="button" className="action-btn delete-btn" onClick={() => deleteGitRepo(r.id)} title="Remove repo">
<i className="fas fa-trash" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
{loading ? (
<p>Loading skills...</p>
) : skills.length === 0 ? (
<div className="card">
<p>No skills found. Create a skill or import one.</p>
<Link to="/skills/new" className="action-btn success" style={{ marginTop: '0.5rem' }}>Create skill</Link>
</div>
) : (
<div className="skills-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{skills.map((s) => (
<div key={s.name} className="card" style={{ padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>{s.name}</h3>
{s.readOnly && <span className="badge" style={{ fontSize: '0.75rem' }}>Read-only</span>}
</div>
<p style={{ margin: '0 0 0.75rem 0', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{s.description || 'No description'}
</p>
<div className="agent-table-actions" style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{!s.readOnly && (
<Link to={`/skills/edit/${encodeURIComponent(s.name)}`} className="action-btn" title="Edit skill">
<i className="fas fa-edit" /> Edit
</Link>
)}
{!s.readOnly && (
<button type="button" className="action-btn delete-btn" onClick={() => deleteSkill(s.name)} title="Delete skill">
<i className="fas fa-trash" /> Delete
</button>
)}
<button type="button" className="action-btn" onClick={() => exportSkill(s.name)} title="Export as .tar.gz">
<i className="fas fa-download" /> Export
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default Skills;
-19
View File
@@ -9,9 +9,6 @@ import ActionsPlayground from './pages/ActionsPlayground';
import GroupCreate from './pages/GroupCreate';
import AgentStatus from './pages/AgentStatus';
import ImportAgent from './pages/ImportAgent';
import Skills from './pages/Skills';
import SkillEdit from './pages/SkillEdit';
import Knowledge from './pages/Knowledge';
// Get the base URL from Vite's environment variables or default to '/app/'
const BASE_URL = import.meta.env.BASE_URL || '/app';
@@ -57,22 +54,6 @@ export const router = createBrowserRouter([
{
path: 'status/:name',
element: <AgentStatus />
},
{
path: 'skills',
element: <Skills />
},
{
path: 'skills/new',
element: <SkillEdit />
},
{
path: 'skills/edit/:name',
element: <SkillEdit />
},
{
path: 'knowledge',
element: <Knowledge />
}
]
}
+44 -103
View File
@@ -1,40 +1,53 @@
/* LocalAGI Theme - CSS Variables System */
/* Supports both Dark and Light themes */
/* Inspired by LocalAI's elegant professional design */
/* ===================================
BASE VARIABLES (Shared)
=================================== */
:root {
/* Brand Colors - Primary Palette (Consistent across themes) */
--color-primary: #38BDF8;
--color-primary-hover: #0EA5E9;
--color-primary-active: #0284C7;
--color-primary-text: #FFFFFF;
/* Background Colors */
--color-bg-primary: #0F172A; /* Deep navy background */
--color-bg-secondary: #1E293B; /* Elevated surfaces */
--color-bg-tertiary: #1E293B; /* Cards, panels */
--color-bg-overlay: rgba(15, 23, 42, 0.8); /* Modals, overlays */
/* Brand Colors - Primary Palette */
--color-primary: #38BDF8; /* Cyan - primary actions */
--color-primary-hover: #0EA5E9; /* Darker cyan on hover */
--color-primary-active: #0284C7; /* Active state */
--color-primary-text: #FFFFFF; /* Text on primary background */
--color-primary-light: rgba(56, 189, 248, 0.08);
--color-primary-border: rgba(56, 189, 248, 0.15);
/* Secondary Colors */
--color-secondary: #14B8A6;
--color-secondary: #14B8A6; /* Teal - secondary actions */
--color-secondary-hover: #0D9488;
--color-secondary-light: rgba(20, 184, 166, 0.1);
/* Accent Colors */
--color-accent: #8B5CF6;
--color-accent: #8B5CF6; /* Purple - special states */
--color-accent-hover: #7C3AED;
--color-accent-light: rgba(139, 92, 246, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
--color-accent-purple: #A78BFA; /* Light purple for gradients */
--color-accent-teal: #2DD4BF; /* Light teal for gradients */
/* Status Colors (Consistent across themes) */
/* Text Colors */
--color-text-primary: #E5E7EB; /* Primary text */
--color-text-secondary: #94A3B8; /* Secondary text */
--color-text-muted: #64748B; /* Tertiary/muted text */
--color-text-disabled: #475569; /* Disabled text */
--color-text-inverse: #0F172A; /* Text on light backgrounds */
/* Border Colors */
--color-border: rgba(148, 163, 184, 0.12);
--color-border-subtle: rgba(148, 163, 184, 0.08);
--color-border-strong: rgba(56, 189, 248, 0.2);
--color-border-focus: rgba(56, 189, 248, 0.3);
/* Status Colors */
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-success-dark: #0D9488;
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-warning-dark: #D97706;
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-error-dark: #DC2626;
--color-info: #38BDF8;
--color-info-light: rgba(56, 189, 248, 0.1);
@@ -47,7 +60,6 @@
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
--shadow-glow: 0 0 0 1px rgba(56, 189, 248, 0.1), 0 0 8px rgba(56, 189, 248, 0.15);
/* Animation Timing */
@@ -58,9 +70,9 @@
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-full: 9999px;
/* Spacing Scale */
@@ -70,95 +82,24 @@
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Layout */
--sidebar-width: 240px;
--sidebar-collapsed-width: 64px;
--header-height: 64px;
/* Legacy Variable Mappings */
/* Legacy Variable Mappings (for backward compatibility) */
--primary: var(--color-primary);
--secondary: var(--color-secondary);
--tertiary: var(--color-accent);
--success: var(--color-success);
--danger: var(--color-error);
--warning: var(--color-warning);
--info: var(--color-info);
}
/* ===================================
DARK THEME (Default)
=================================== */
[data-theme="dark"],
:root:not([data-theme]) {
/* Background Colors */
--color-bg-primary: #0F172A;
--color-bg-secondary: #1E293B;
--color-bg-tertiary: #334155;
--color-bg-elevated: #1E293B;
--color-bg-overlay: rgba(15, 23, 42, 0.8);
--color-bg-input: #0F172A;
/* Text Colors */
--color-text-primary: #F1F5F9;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-text-disabled: #475569;
--color-text-inverse: #0F172A;
--color-text-placeholder: #64748B;
/* Border Colors */
--color-border: rgba(148, 163, 184, 0.15);
--color-border-subtle: rgba(148, 163, 184, 0.08);
--color-border-strong: rgba(148, 163, 184, 0.25);
--color-border-focus: rgba(56, 189, 248, 0.4);
/* Legacy mappings */
--dark-bg: var(--color-bg-primary);
--darker-bg: var(--color-bg-primary);
--medium-bg: var(--color-bg-secondary);
--light-bg: var(--color-bg-tertiary);
--text: var(--color-text-primary);
--border: var(--color-border);
}
/* ===================================
LIGHT THEME
=================================== */
[data-theme="light"] {
/* Background Colors - Warm off-white/cream palette */
--color-bg-primary: #F8F7F4;
--color-bg-secondary: #FFFFFF;
--color-bg-tertiary: #F1F0EC;
--color-bg-elevated: #FFFFFF;
--color-bg-overlay: rgba(255, 255, 255, 0.9);
--color-bg-input: #FFFFFF;
/* Text Colors */
--color-text-primary: #1E293B;
--color-text-secondary: #64748B;
--color-text-muted: #94A3B8;
--color-text-disabled: #CBD5E1;
--color-text-inverse: #FFFFFF;
--color-text-placeholder: #94A3B8;
/* Border Colors */
--color-border: rgba(148, 163, 184, 0.25);
--color-border-subtle: rgba(148, 163, 184, 0.15);
--color-border-strong: rgba(148, 163, 184, 0.35);
--color-border-focus: rgba(56, 189, 248, 0.5);
/* Adjusted shadows for light theme */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.08);
/* Legacy mappings */
--dark-bg: var(--color-bg-secondary);
--darker-bg: var(--color-bg-tertiary);
--medium-bg: var(--color-bg-primary);
--light-bg: var(--color-bg-secondary);
--text: var(--color-text-primary);
--border: var(--color-border);
--success: var(--color-success);
--danger: var(--color-error);
--warning: var(--color-warning);
--info: var(--color-info);
/* Remove old glow effects - use subtle shadows instead */
--neon-glow: none;
--pink-glow: none;
--purple-glow: none;
}
-229
View File
@@ -24,16 +24,6 @@ const buildUrl = (endpoint) => {
return `${API_CONFIG.baseUrl}${endpoint.startsWith('/') ? endpoint.substring(1) : endpoint}`;
};
// Collections API returns { success, message, data, error }. Throw if !ok or !success.
const handleCollectionsResponse = async (response) => {
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const msg = data.error?.message || data.error?.details || data.message || `API error: ${response.status}`;
throw new Error(msg);
}
return data;
};
// Helper function to convert ActionDefinition to FormFieldDefinition format
const convertActionDefinitionToFields = (definition) => {
if (!definition || !definition.Properties) {
@@ -303,222 +293,3 @@ export const statusApi = {
return handleResponse(response);
},
};
// Skills API (skills are stored under state dir / skills, not configurable)
export const skillsApi = {
getConfig: async () => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsConfig));
return handleResponse(response);
},
list: async () => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsList));
return handleResponse(response);
},
search: async (q) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsSearch(q)));
return handleResponse(response);
},
get: async (name) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)));
return handleResponse(response);
},
create: async (data) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsList), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify(data),
});
return handleResponse(response);
},
update: async (name, data) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)), {
method: 'PUT',
headers: API_CONFIG.headers,
body: JSON.stringify(data),
});
return handleResponse(response);
},
delete: async (name) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skill(name)), { method: 'DELETE' });
if (response.status === 204) return;
return handleResponse(response);
},
import: async (file) => {
const form = new FormData();
form.append('file', file);
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillsImport), {
method: 'POST',
body: form,
});
return handleResponse(response);
},
exportUrl: (name) => buildUrl(API_CONFIG.endpoints.skillExport(name)),
listResources: async (name) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResources(name)));
return handleResponse(response);
},
getResource: async (name, path, { json = false } = {}) => {
const url = buildUrl(API_CONFIG.endpoints.skillResource(name, path)) + (json ? '?encoding=base64' : '');
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || `Failed to get resource: ${response.status}`);
}
if (json) return response.json();
const ct = response.headers.get('content-type') || '';
if (ct.includes('application/json')) return response.json();
if (ct.includes('text/') || ct.includes('application/javascript')) return response.text();
return response.blob();
},
createResource: async (name, path, file) => {
const form = new FormData();
form.append('file', file);
form.append('path', path);
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResources(name)), {
method: 'POST',
body: form,
credentials: 'same-origin',
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || `Failed to create resource: ${response.status}`);
}
return response.json();
},
updateResource: async (name, path, content) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResource(name, path)), {
method: 'PUT',
headers: API_CONFIG.headers,
body: JSON.stringify({ content }),
credentials: 'same-origin',
});
if (response.status !== 204) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || `Failed to update resource: ${response.status}`);
}
},
deleteResource: async (name, path) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.skillResource(name, path)), {
method: 'DELETE',
credentials: 'same-origin',
});
if (response.status !== 204) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || `Failed to delete resource: ${response.status}`);
}
},
listGitRepos: async () => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepos));
return handleResponse(response);
},
addGitRepo: async (url) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepos), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ url }),
});
return handleResponse(response);
},
updateGitRepo: async (id, data) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepo(id)), {
method: 'PUT',
headers: API_CONFIG.headers,
body: JSON.stringify(data),
});
return handleResponse(response);
},
deleteGitRepo: async (id) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepo(id)), { method: 'DELETE' });
if (response.status === 204) return;
return handleResponse(response);
},
syncGitRepo: async (id) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepoSync(id)), { method: 'POST' });
return handleResponse(response);
},
toggleGitRepo: async (id) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.gitRepoToggle(id)), { method: 'POST' });
return handleResponse(response);
},
};
// Collections / knowledge base API (LocalRecall-compatible)
export const collectionsApi = {
list: async () => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collections));
const data = await handleCollectionsResponse(response);
return data.data?.collections || [];
},
create: async (name) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collections), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ name }),
});
return handleCollectionsResponse(response);
},
upload: async (collectionName, file) => {
const form = new FormData();
form.append('file', file);
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionUpload(collectionName)), {
method: 'POST',
body: form,
});
return handleCollectionsResponse(response);
},
listEntries: async (collectionName) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionEntries(collectionName)));
const data = await handleCollectionsResponse(response);
return data.data?.entries || [];
},
getEntryContent: async (collectionName, entry) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionEntry(collectionName, entry)));
const data = await handleCollectionsResponse(response);
return { content: data.data?.content ?? '', chunkCount: data.data?.chunk_count ?? 0, entry: data.data?.entry ?? entry };
},
search: async (collectionName, query, maxResults = 5) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSearch(collectionName)), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ query, max_results: maxResults }),
});
const data = await handleCollectionsResponse(response);
return data.data?.results || [];
},
reset: async (collectionName) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionReset(collectionName)), {
method: 'POST',
headers: API_CONFIG.headers,
});
return handleCollectionsResponse(response);
},
deleteEntry: async (collectionName, entry) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionDeleteEntry(collectionName)), {
method: 'DELETE',
headers: API_CONFIG.headers,
body: JSON.stringify({ entry }),
});
return handleCollectionsResponse(response);
},
listSources: async (collectionName) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName)));
const data = await handleCollectionsResponse(response);
return data.data?.sources || [];
},
addSource: async (collectionName, url, updateIntervalMinutes = 60) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName)), {
method: 'POST',
headers: API_CONFIG.headers,
body: JSON.stringify({ url, update_interval: updateIntervalMinutes }),
});
return handleCollectionsResponse(response);
},
removeSource: async (collectionName, url) => {
const response = await fetch(buildUrl(API_CONFIG.endpoints.collectionSources(collectionName)), {
method: 'DELETE',
headers: API_CONFIG.headers,
body: JSON.stringify({ url }),
});
return handleCollectionsResponse(response);
},
};
-24
View File
@@ -48,29 +48,5 @@ export const API_CONFIG = {
// Status endpoint
status: (name) => `/status/${name}`,
// Skills endpoints
skillsConfig: '/api/skills/config',
skillsList: '/api/skills',
skillsSearch: (q) => `/api/skills/search?q=${encodeURIComponent(q)}`,
skill: (name) => `/api/skills/${encodeURIComponent(name)}`,
skillsImport: '/api/skills/import',
skillExport: (name) => `/api/skills/export/${encodeURIComponent(name)}`,
skillResources: (name) => `/api/skills/${encodeURIComponent(name)}/resources`,
skillResource: (name, path) => `/api/skills/${encodeURIComponent(name)}/resources/${path.split('/').map(encodeURIComponent).join('/')}`,
gitRepos: '/api/git-repos',
gitRepo: (id) => `/api/git-repos/${id}`,
gitRepoSync: (id) => `/api/git-repos/${id}/sync`,
gitRepoToggle: (id) => `/api/git-repos/${id}/toggle`,
// Collections / knowledge base (LocalRecall-compatible)
collections: '/api/collections',
collectionUpload: (name) => `/api/collections/${encodeURIComponent(name)}/upload`,
collectionEntries: (name) => `/api/collections/${encodeURIComponent(name)}/entries`,
collectionEntry: (name, entry) => `/api/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`,
collectionSearch: (name) => `/api/collections/${encodeURIComponent(name)}/search`,
collectionReset: (name) => `/api/collections/${encodeURIComponent(name)}/reset`,
collectionDeleteEntry: (name) => `/api/collections/${encodeURIComponent(name)}/entry/delete`,
collectionSources: (name) => `/api/collections/${encodeURIComponent(name)}/sources`,
}
};
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
// Define backend URL with port from environment variable or default to 8080
const backendUrl = `http://${env.LOCALAGI_BASE_URL || 'localhost:3000'}`
const backendUrl = `http://${env.BACKEND_HOST || 'localhost'}:${env.BACKEND_PORT || '3000'}`
return {
plugins: [react()],
+83 -42
View File
@@ -17,7 +17,6 @@ import (
"github.com/mudler/LocalAGI/core/state"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/LocalAGI/services"
"github.com/mudler/xlog"
)
@@ -35,10 +34,18 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
webapp.Use(v2keyauth.New(*kaConfig))
}
webapp.Get("/old", func(c *fiber.Ctx) error {
return c.Render("old/views/index", fiber.Map{
"Agents": pool.List(),
"AgentCount": len(pool.List()),
"Actions": len(services.AvailableActions),
"Connectors": len(services.AvailableConnectors),
})
})
webapp.Get("/", func(c *fiber.Ctx) error {
return c.Redirect("/app")
})
webapp.Use("/app", filesystem.New(filesystem.Config{
Root: http.FS(reactUI),
PathPrefix: "react-ui/dist",
@@ -54,6 +61,29 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
return c.Send(indexHTML)
})
webapp.Get("/old/agents", func(c *fiber.Ctx) error {
statuses := map[string]bool{}
for _, a := range pool.List() {
agent := pool.GetAgent(a)
if agent == nil {
xlog.Error("Agent not found", "name", a)
continue
}
statuses[a] = !agent.Paused()
}
return c.Render("old/views/agents", fiber.Map{
"Agents": pool.List(),
"Status": statuses,
})
})
webapp.Get("/old/create", func(c *fiber.Ctx) error {
return c.Render("old/views/create", fiber.Map{
"Actions": services.AvailableActions,
"Connectors": services.AvailableConnectors,
"PromptBlocks": services.AvailableBlockPrompts,
})
})
// Define a route for the GET method on the root path '/'
webapp.Get("/sse/:name", func(c *fiber.Ctx) error {
m := pool.GetManager(c.Params("name"))
@@ -65,7 +95,21 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
return nil
})
webapp.Get("/old/status/:name", func(c *fiber.Ctx) error {
history := pool.GetStatusHistory(c.Params("name"))
if history == nil {
history = &state.Status{ActionResults: []types.ActionState{}}
}
// reverse history
return c.Render("old/views/status", fiber.Map{
"Name": c.Params("name"),
"History": Reverse(history.Results()),
})
})
webapp.Get("/api/notify/:name", app.Notify(pool))
webapp.Post("/old/chat/:name", app.OldChat(pool))
webapp.Post("/api/agent/create", app.Create(pool))
webapp.Delete("/api/agent/:name", app.Delete(pool))
@@ -74,14 +118,46 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
webapp.Post("/api/chat/:name", app.Chat(pool))
webapp.Get("/login", func(c *fiber.Ctx) error {
return c.Status(401).Redirect("/app") // After login, just redirect to index
})
conversationTracker := conversations.NewConversationTracker[string](app.config.ConversationStoreDuration)
webapp.Post("/v1/responses", app.Responses(pool, conversationTracker))
webapp.Get("/old/talk/:name", func(c *fiber.Ctx) error {
return c.Render("old/views/chat", fiber.Map{
// "Character": agent.Character,
"Name": c.Params("name"),
})
})
webapp.Get("/old/settings/:name", func(c *fiber.Ctx) error {
status := false
for _, a := range pool.List() {
if a == c.Params("name") {
status = !pool.GetAgent(a).Paused()
}
}
return c.Render("old/views/settings", fiber.Map{
"Name": c.Params("name"),
"Status": status,
"Actions": services.AvailableActions,
"Connectors": services.AvailableConnectors,
"PromptBlocks": services.AvailableBlockPrompts,
})
})
webapp.Get("/old/actions-playground", func(c *fiber.Ctx) error {
return c.Render("old/views/actions", fiber.Map{})
})
webapp.Get("/old/group-create", func(c *fiber.Ctx) error {
return c.Render("old/views/group-create", fiber.Map{
"Actions": services.AvailableActions,
"Connectors": services.AvailableConnectors,
"PromptBlocks": services.AvailableBlockPrompts,
})
})
// New API endpoints for getting and updating agent configuration
webapp.Get("/api/agent/:name/config", app.GetAgentConfig(pool))
webapp.Put("/api/agent/:name/config", app.UpdateAgentConfig(pool))
@@ -192,39 +268,6 @@ func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
webapp.Post("/settings/import", app.ImportAgent(pool))
webapp.Get("/settings/export/:name", app.ExportAgent(pool))
// Skills API (when app.config.SkillsService is set)
webapp.Get("/api/skills/config", app.GetSkillsConfig)
webapp.Get("/api/skills", app.ListSkills)
webapp.Get("/api/skills/search", app.SearchSkills)
webapp.Post("/api/skills", app.CreateSkill)
webapp.Get("/api/skills/export/*", app.ExportSkill)
webapp.Post("/api/skills/import", app.ImportSkill)
webapp.Get("/api/skills/:name", app.GetSkill)
webapp.Put("/api/skills/:name", app.UpdateSkill)
webapp.Delete("/api/skills/:name", app.DeleteSkill)
webapp.Get("/api/skills/:name/resources", app.ListSkillResources)
webapp.Get("/api/skills/:name/resources/*", app.GetSkillResource)
webapp.Post("/api/skills/:name/resources", app.CreateSkillResource)
webapp.Put("/api/skills/:name/resources/*", app.UpdateSkillResource)
webapp.Delete("/api/skills/:name/resources/*", app.DeleteSkillResource)
webapp.Get("/api/git-repos", app.ListGitRepos)
webapp.Post("/api/git-repos", app.AddGitRepo)
webapp.Put("/api/git-repos/:id", app.UpdateGitRepo)
webapp.Delete("/api/git-repos/:id", app.DeleteGitRepo)
webapp.Post("/api/git-repos/:id/sync", app.SyncGitRepo)
webapp.Post("/api/git-repos/:id/toggle", app.ToggleGitRepo)
// Collections / knowledge base API (LocalRecall-compatible). Same interface for in-process or remote.
var collectionsBackend CollectionsBackend
if app.config.LocalRAGURL != "" {
client := localrag.NewClient(app.config.LocalRAGURL, app.config.LLMAPIKey)
collectionsBackend = NewCollectionsBackendHTTP(client)
} else {
var state *CollectionsState
collectionsBackend, state = NewInProcessCollectionsBackend(app.config)
app.collectionsState = state
}
app.RegisterCollectionRoutes(webapp, app.config, collectionsBackend)
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
@@ -274,9 +317,7 @@ func getApiKeyErrorHandler(opaqueErrors bool, apiKeys []string) fiber.ErrorHandl
if opaqueErrors {
return ctx.SendStatus(401)
}
return ctx.Status(401).Render("public/views/login", fiber.Map{
"Title": "Login Required",
})
return ctx.Status(401).Render("old/views/login", fiber.Map{})
}
if opaqueErrors {
return ctx.SendStatus(500)
-861
View File
@@ -1,861 +0,0 @@
package webui
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/mudler/LocalAGI/services/skills"
"github.com/mudler/xlog"
skilldomain "github.com/mudler/skillserver/pkg/domain"
skillgit "github.com/mudler/skillserver/pkg/git"
)
type skillResponse struct {
Name string `json:"name"`
Content string `json:"content"`
Description string `json:"description,omitempty"`
License string `json:"license,omitempty"`
Compatibility string `json:"compatibility,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
AllowedTools string `json:"allowed-tools,omitempty"`
ReadOnly bool `json:"readOnly"`
}
type createSkillRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Content string `json:"content"`
License string `json:"license,omitempty"`
Compatibility string `json:"compatibility,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
AllowedTools string `json:"allowed-tools,omitempty"`
}
type updateSkillRequest struct {
Description string `json:"description"`
Content string `json:"content"`
License string `json:"license,omitempty"`
Compatibility string `json:"compatibility,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
AllowedTools string `json:"allowed-tools,omitempty"`
}
func skillToResponse(s skilldomain.Skill) skillResponse {
out := skillResponse{Name: s.Name, Content: s.Content, ReadOnly: s.ReadOnly}
if s.Metadata != nil {
out.Description = s.Metadata.Description
out.License = s.Metadata.License
out.Compatibility = s.Metadata.Compatibility
out.Metadata = s.Metadata.Metadata
out.AllowedTools = s.Metadata.AllowedTools
}
return out
}
func (a *App) skillsSvc() *skills.Service {
if a.config == nil {
return nil
}
return a.config.SkillsService
}
func skillsUnavailable(c *fiber.Ctx) error {
return c.Status(http.StatusServiceUnavailable).JSON(fiber.Map{"error": "skills service not available"})
}
func skillsNoDir(c *fiber.Ctx) error {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "skills directory not configured"})
}
// decodeSkillNameParam decodes a URL-encoded skill name (e.g. repo%2Fskill -> repo/skill).
func decodeSkillNameParam(raw string) string {
if raw == "" {
return ""
}
decoded, err := url.PathUnescape(raw)
if err != nil {
return raw
}
return decoded
}
func (a *App) GetSkillsConfig(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
return c.JSON(fiber.Map{"skills_dir": svc.GetSkillsDir()})
}
func (a *App) ListSkills(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
if mgr == nil {
return c.Status(http.StatusOK).JSON([]skillResponse{})
}
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
list, err := mgr.ListSkills()
if err != nil {
xlog.Error("[skills] ListSkills: mgr.ListSkills failed", "error", err)
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]skillResponse, len(list))
for i, s := range list {
out[i] = skillToResponse(s)
}
return c.JSON(out)
}
func (a *App) SearchSkills(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
q := c.Query("q")
if q == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "query parameter 'q' is required"})
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
list, err := mgr.SearchSkills(q)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]skillResponse, len(list))
for i, s := range list {
out[i] = skillToResponse(s)
}
return c.JSON(out)
}
func (a *App) GetSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
name := decodeSkillNameParam(c.Params("name"))
skill, err := mgr.ReadSkill(name)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
return c.JSON(skillToResponse(*skill))
}
func (a *App) CreateSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
}
var req createSkillRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if req.Name == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
if err := skilldomain.ValidateSkillName(req.Name); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if req.Description == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description is required"})
}
if len(req.Description) > 1024 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description must be 1-1024 characters"})
}
if req.Compatibility != "" && len(req.Compatibility) > 500 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "compatibility must be max 500 characters"})
}
skillsDir := fsManager.GetSkillsDir()
skillDir := filepath.Join(skillsDir, req.Name)
// Prevent overwriting an existing skill directory/content
if _, err := os.Stat(skillDir); err == nil {
return c.Status(http.StatusConflict).JSON(fiber.Map{"error": "skill already exists"})
} else if !os.IsNotExist(err) {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", req.Name, req.Description)
if req.License != "" {
frontmatter += fmt.Sprintf("license: %s\n", req.License)
}
if req.Compatibility != "" {
frontmatter += fmt.Sprintf("compatibility: %s\n", req.Compatibility)
}
if len(req.Metadata) > 0 {
frontmatter += "metadata:\n"
for k, v := range req.Metadata {
frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
}
}
if req.AllowedTools != "" {
frontmatter += fmt.Sprintf("allowed-tools: %s\n", req.AllowedTools)
}
frontmatter += "---\n\n"
skillMdPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillMdPath, []byte(frontmatter+req.Content), 0644); err != nil {
os.RemoveAll(skillDir)
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if err := mgr.RebuildIndex(); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
}
skill, err := mgr.ReadSkill(req.Name)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read created skill"})
}
return c.Status(http.StatusCreated).JSON(skillToResponse(*skill))
}
func (a *App) UpdateSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
}
name := decodeSkillNameParam(c.Params("name"))
existing, err := mgr.ReadSkill(name)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
if existing.ReadOnly {
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot update read-only skill from git repository"})
}
var req updateSkillRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if req.Description == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description is required"})
}
if len(req.Description) > 1024 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "description must be 1-1024 characters"})
}
if req.Compatibility != "" && len(req.Compatibility) > 500 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "compatibility must be max 500 characters"})
}
skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
frontmatter := fmt.Sprintf("---\nname: %s\ndescription: %s\n", name, req.Description)
if req.License != "" {
frontmatter += fmt.Sprintf("license: %s\n", req.License)
}
if req.Compatibility != "" {
frontmatter += fmt.Sprintf("compatibility: %s\n", req.Compatibility)
}
if len(req.Metadata) > 0 {
frontmatter += "metadata:\n"
for k, v := range req.Metadata {
frontmatter += fmt.Sprintf(" %s: %s\n", k, v)
}
}
if req.AllowedTools != "" {
frontmatter += fmt.Sprintf("allowed-tools: %s\n", req.AllowedTools)
}
frontmatter += "---\n\n"
skillMdPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillMdPath, []byte(frontmatter+req.Content), 0644); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if err := mgr.RebuildIndex(); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
}
skill, err := mgr.ReadSkill(name)
if err != nil || skill == nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read updated skill"})
}
return c.JSON(skillToResponse(*skill))
}
func (a *App) DeleteSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
}
name := decodeSkillNameParam(c.Params("name"))
existing, err := mgr.ReadSkill(name)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
if existing.ReadOnly {
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot delete read-only skill from git repository"})
}
skillDir := filepath.Join(fsManager.GetSkillsDir(), name)
if err := os.RemoveAll(skillDir); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if err := mgr.RebuildIndex(); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
}
return c.SendStatus(http.StatusNoContent)
}
func (a *App) ExportSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
}
rawName := strings.TrimPrefix(c.Params("*"), "/")
if rawName == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "skill name required"})
}
name := decodeSkillNameParam(rawName)
skill, err := mgr.ReadSkill(name)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
archiveData, err := skilldomain.ExportSkill(skill.ID, fsManager.GetSkillsDir())
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
c.Set("Content-Type", "application/gzip")
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.tar.gz\"", name))
return c.Send(archiveData)
}
func (a *App) ImportSkill(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
fsManager, ok := mgr.(*skilldomain.FileSystemManager)
if !ok {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "unsupported manager type"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
}
src, err := file.Open()
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to open uploaded file"})
}
defer src.Close()
const maxArchiveSize = 50 * 1024 * 1024
if file.Size > maxArchiveSize {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "archive too large"})
}
if file.Size <= 0 {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid file size"})
}
archiveData := make([]byte, int(file.Size))
n, err := io.ReadFull(src, archiveData)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to read file"})
}
archiveData = archiveData[:n]
skillName, err := skilldomain.ImportSkill(archiveData, fsManager.GetSkillsDir())
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
if err := mgr.RebuildIndex(); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to rebuild index"})
}
skill, err := mgr.ReadSkill(skillName)
if err != nil || skill == nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "failed to read imported skill"})
}
return c.Status(http.StatusCreated).JSON(skillToResponse(*skill))
}
func (a *App) ListSkillResources(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
skillName := decodeSkillNameParam(c.Params("name"))
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
resources, err := mgr.ListSkillResources(skill.ID)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
scripts := []map[string]interface{}{}
references := []map[string]interface{}{}
assets := []map[string]interface{}{}
for _, res := range resources {
m := map[string]interface{}{
"path": res.Path,
"name": res.Name,
"size": res.Size,
"mime_type": res.MimeType,
"readable": res.Readable,
"modified": res.Modified.Format("2006-01-02T15:04:05Z07:00"),
}
switch res.Type {
case skilldomain.ResourceTypeScript:
scripts = append(scripts, m)
case skilldomain.ResourceTypeReference:
references = append(references, m)
case skilldomain.ResourceTypeAsset:
assets = append(assets, m)
}
}
return c.JSON(fiber.Map{"scripts": scripts, "references": references, "assets": assets, "readOnly": skill.ReadOnly})
}
func (a *App) GetSkillResource(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
skillName := decodeSkillNameParam(c.Params("name"))
resourcePath := c.Params("*")
if resourcePath == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
info, err := mgr.GetSkillResourceInfo(skill.ID, resourcePath)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "resource not found"})
}
content, err := mgr.ReadSkillResource(skill.ID, resourcePath)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if c.Query("encoding") == "base64" || !info.Readable {
return c.JSON(fiber.Map{"content": content.Content, "encoding": content.Encoding, "mime_type": content.MimeType, "size": content.Size})
}
c.Set("Content-Type", content.MimeType)
return c.SendString(content.Content)
}
func (a *App) CreateSkillResource(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
skillName := decodeSkillNameParam(c.Params("name"))
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
if skill.ReadOnly {
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot add resources to read-only skill"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "file is required"})
}
path := c.FormValue("path")
if path == "" {
path = file.Filename
}
if err := skilldomain.ValidateResourcePath(path); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
fullPath := filepath.Join(skill.SourcePath, path)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
src, err := file.Open()
if err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "failed to open file"})
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if err := os.WriteFile(fullPath, data, 0644); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(http.StatusCreated).JSON(fiber.Map{"path": path})
}
func (a *App) UpdateSkillResource(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
skillName := decodeSkillNameParam(c.Params("name"))
resourcePath := c.Params("*")
if resourcePath == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
if skill.ReadOnly {
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot update resources in read-only skill"})
}
if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
fullPath := filepath.Join(skill.SourcePath, resourcePath)
var body struct {
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if err := os.WriteFile(fullPath, []byte(body.Content), 0644); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(http.StatusNoContent)
}
func (a *App) DeleteSkillResource(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
return skillsNoDir(c)
}
skillName := decodeSkillNameParam(c.Params("name"))
resourcePath := c.Params("*")
if resourcePath == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "resource path is required"})
}
skill, err := mgr.ReadSkill(skillName)
if err != nil {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "skill not found"})
}
if skill.ReadOnly {
return c.Status(http.StatusForbidden).JSON(fiber.Map{"error": "cannot delete resources from read-only skill"})
}
if err := skilldomain.ValidateResourcePath(resourcePath); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
}
fullPath := filepath.Join(skill.SourcePath, resourcePath)
if err := os.Remove(fullPath); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(http.StatusNoContent)
}
// Git repos: list, add, update, delete, sync, toggle (using ConfigManager in skills dir)
func (a *App) ListGitRepos(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return c.Status(http.StatusOK).JSON([]gitRepoResponse{})
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
out := make([]gitRepoResponse, len(repos))
for i, r := range repos {
out[i] = gitRepoResponse{ID: r.ID, URL: r.URL, Name: r.Name, Enabled: r.Enabled}
}
return c.JSON(out)
}
type gitRepoResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
}
func (a *App) AddGitRepo(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return skillsNoDir(c)
}
var req struct {
URL string `json:"url"`
}
if err := c.BodyParser(&req); err != nil || req.URL == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "URL is required"})
}
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") && !strings.HasPrefix(req.URL, "git@") {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid URL format"})
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
for _, r := range repos {
if r.URL == req.URL {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "repository already exists"})
}
}
newRepo := skillgit.GitRepoConfig{
ID: skillgit.GenerateID(req.URL),
URL: req.URL,
Name: skillgit.ExtractRepoName(req.URL),
Enabled: true,
}
repos = append(repos, newRepo)
if err := cm.SaveConfig(repos); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Do not invalidate here: the new repo is not cloned yet. Keep the current manager
// so ListSkills returns immediately and the sync goroutine gets the cache without contention.
urlToSync := req.URL
xlog.Debug("[skills] AddGitRepo: repo saved, starting background sync", "url", urlToSync)
go func() {
xlog.Debug("[skills] background sync: started", "url", urlToSync)
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
xlog.Error("[skills] background sync: GetManager failed", "url", urlToSync, "error", err)
return
}
xlog.Debug("[skills] background sync: got manager, running syncer", "url", urlToSync)
syncer := skillgit.NewGitSyncer(dir, []string{urlToSync}, mgr.RebuildIndex)
if err := syncer.Start(); err != nil {
xlog.Error("[skills] background sync: sync failed", "url", urlToSync, "error", err)
svc.RefreshManagerFromConfig()
return
}
syncer.Stop()
svc.RefreshManagerFromConfig()
xlog.Debug("[skills] background sync: finished", "url", urlToSync)
}()
xlog.Debug("[skills] AddGitRepo: returning 201 (sync in progress)")
return c.Status(http.StatusCreated).JSON(gitRepoResponse{ID: newRepo.ID, URL: newRepo.URL, Name: newRepo.Name, Enabled: newRepo.Enabled})
}
func (a *App) UpdateGitRepo(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return skillsNoDir(c)
}
id := c.Params("id")
var req struct {
URL string `json:"url"`
Enabled *bool `json:"enabled"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var found int
for i, r := range repos {
if r.ID == id {
found = i
if req.URL != "" {
parsedURL, err := url.Parse(req.URL)
if err != nil || parsedURL.Scheme == "" {
return c.Status(http.StatusBadRequest).JSON(fiber.Map{"error": "invalid repository URL"})
}
repos[i].URL = req.URL
repos[i].Name = skillgit.ExtractRepoName(req.URL)
}
if req.Enabled != nil {
repos[i].Enabled = *req.Enabled
}
break
}
}
if found >= len(repos) || repos[found].ID != id {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
}
if err := cm.SaveConfig(repos); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
svc.RefreshManagerFromConfig()
return c.JSON(gitRepoResponse{ID: repos[found].ID, URL: repos[found].URL, Name: repos[found].Name, Enabled: repos[found].Enabled})
}
func (a *App) DeleteGitRepo(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return skillsNoDir(c)
}
id := c.Params("id")
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var newRepos []skillgit.GitRepoConfig
var repoName string
for _, r := range repos {
if r.ID == id {
repoName = r.Name
} else {
newRepos = append(newRepos, r)
}
}
if len(newRepos) == len(repos) {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
}
if err := cm.SaveConfig(newRepos); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if repoName != "" {
repoDir := filepath.Join(dir, repoName)
if err := os.RemoveAll(repoDir); err != nil {
xlog.Debug("[skills] DeleteGitRepo: failed to remove repo directory", "dir", repoDir, "error", err)
}
}
svc.RefreshManagerFromConfig()
return c.SendStatus(http.StatusNoContent)
}
func (a *App) SyncGitRepo(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return skillsNoDir(c)
}
id := c.Params("id")
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
var url string
for _, r := range repos {
if r.ID == id {
url = r.URL
break
}
}
if url == "" {
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
}
xlog.Debug("[skills] SyncGitRepo: requested", "id", id, "url", url)
mgr, err := svc.GetManager()
if err != nil || mgr == nil {
xlog.Error("[skills] SyncGitRepo: GetManager failed", "id", id, "error", err)
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": "manager not ready"})
}
go func() {
xlog.Debug("[skills] SyncGitRepo: background sync started", "id", id, "url", url)
syncer := skillgit.NewGitSyncer(dir, []string{url}, mgr.RebuildIndex)
if err := syncer.Start(); err != nil {
xlog.Error("[skills] SyncGitRepo: background sync failed", "id", id, "error", err)
svc.RefreshManagerFromConfig()
return
}
syncer.Stop()
svc.RefreshManagerFromConfig()
xlog.Debug("[skills] SyncGitRepo: background sync finished", "id", id)
}()
xlog.Debug("[skills] SyncGitRepo: returning 200 (sync in progress)")
return c.JSON(fiber.Map{"status": "ok", "message": "Sync started in background"})
}
func (a *App) ToggleGitRepo(c *fiber.Ctx) error {
svc := a.skillsSvc()
if svc == nil {
return skillsUnavailable(c)
}
dir := svc.GetSkillsDir()
if dir == "" {
return skillsNoDir(c)
}
id := c.Params("id")
cm := skillgit.NewConfigManager(dir)
repos, err := cm.LoadConfig()
if err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
for i, r := range repos {
if r.ID == id {
repos[i].Enabled = !repos[i].Enabled
if err := cm.SaveConfig(repos); err != nil {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
svc.RefreshManagerFromConfig()
return c.JSON(gitRepoResponse{ID: repos[i].ID, URL: repos[i].URL, Name: repos[i].Name, Enabled: repos[i].Enabled})
}
}
return c.Status(http.StatusNotFound).JSON(fiber.Map{"error": "repository not found"})
}
-396
View File
@@ -1,396 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalAGI - Login</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--color-bg-primary: #0F172A;
--color-bg-secondary: #1E293B;
--color-primary: #38BDF8;
--color-primary-hover: #0EA5E9;
--color-primary-light: rgba(56, 189, 248, 0.08);
--color-text-primary: #E5E7EB;
--color-text-secondary: #94A3B8;
--color-text-muted: #64748B;
--color-text-inverse: #0F172A;
--color-border: rgba(148, 163, 184, 0.12);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-success: #14B8A6;
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--radius-md: 6px;
--radius-xl: 12px;
--duration-fast: 150ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
line-height: 1.5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
width: 100%;
padding: 2rem;
}
.login-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
}
.login-background .bg-gradient {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(ellipse at top left, rgba(56, 189, 248, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(56, 189, 248, 0.04) 0%, transparent 50%),
var(--color-bg-primary);
}
.login-container {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 380px;
}
.login-logo {
margin-bottom: 1.5rem;
text-align: center;
animation: fadeInDown 0.5s ease-out;
}
.login-logo img {
max-width: 180px;
height: auto;
}
.login-card {
width: 100%;
background-color: var(--color-bg-secondary);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
padding: 2rem;
animation: fadeInUp 0.5s ease-out 0.1s both;
}
.login-card-header {
text-align: center;
margin-bottom: 1.5rem;
}
.login-card-header h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 0.5rem;
}
.login-card-header p {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--color-text-secondary);
font-weight: 500;
font-size: 0.875rem;
}
.input-wrapper {
position: relative;
}
.input-icon {
position: absolute;
left: 0.875rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
z-index: 1;
}
.form-group input {
width: 100%;
padding: 0.75rem 1rem 0.75rem 2.5rem;
background-color: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-size: 0.95rem;
transition: all var(--duration-fast) var(--ease-default);
}
.form-group input::placeholder {
color: var(--color-text-muted);
}
.form-group input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.login-button {
width: 100%;
padding: 0.75rem 1.5rem;
background-color: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-default);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.login-button:hover {
background-color: var(--color-primary-hover);
}
.login-button:active {
transform: translateY(1px);
}
.login-button i {
transition: transform var(--duration-fast) var(--ease-default);
}
.login-button:hover i {
transform: translateX(3px);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background-color: var(--color-error-light);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.875rem;
display: none;
align-items: center;
gap: 0.5rem;
animation: shake 0.4s ease-out;
}
.login-footer {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
text-align: center;
}
.security-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
.time-display {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.time-display span {
color: var(--color-text-secondary);
font-family: monospace;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 1.5rem;
}
.login-logo img {
max-width: 150px;
}
}
</style>
</head>
<body>
<div class="login-page">
<div class="login-background">
<div class="bg-gradient"></div>
</div>
<div class="login-container">
<!-- Logo -->
<div class="login-logo">
<img src="/public/logo_1.png" alt="LocalAGI Logo" width="180">
</div>
<!-- Auth Card -->
<div class="login-card">
<div class="login-card-header">
<h2>Authorization Required</h2>
<p>Please enter your access token to continue</p>
</div>
<form id="login-form" onsubmit="login(); return false;">
<div class="form-group">
<label for="token">Access Token</label>
<div class="input-wrapper">
<span class="input-icon"><i class="fas fa-key"></i></span>
<input
type="password"
id="token"
name="token"
placeholder="Enter your token"
required
/>
</div>
</div>
<button type="submit" class="login-button">
<span>Login</span>
<i class="fas fa-arrow-right"></i>
</button>
<div id="error-message" class="error-message"></div>
</form>
<div class="login-footer">
<div class="security-badge">
<i class="fas fa-shield-alt"></i>
<span>Instance is token protected</span>
</div>
<p class="time-display">Current time (UTC): <span id="current-time">{{.CurrentDate}}</span></p>
</div>
</div>
</div>
</div>
<script>
function login() {
var token = document.getElementById('token');
var errorMsg = document.getElementById('error-message');
var tokenValue = token.value.trim();
if (!tokenValue) {
errorMsg.innerHTML = '<i class="fas fa-exclamation-circle"></i> Please enter a valid token';
errorMsg.style.display = 'flex';
errorMsg.style.animation = 'none';
errorMsg.offsetHeight;
errorMsg.style.animation = 'shake 0.4s ease-out';
token.focus();
return;
}
var date = new Date();
date.setTime(date.getTime() + (24 * 60 * 60 * 1000));
document.cookie = 'token=' + tokenValue + '; expires=' + date.toGMTString() + '; path=/';
var button = document.querySelector('.login-button');
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Authenticating...</span>';
button.style.opacity = '0.8';
setTimeout(function() {
window.location.reload();
}, 800);
}
function updateCurrentTime() {
var timeElement = document.getElementById('current-time');
if (timeElement) {
var now = new Date();
var year = now.getUTCFullYear();
var month = String(now.getUTCMonth() + 1).padStart(2, '0');
var day = String(now.getUTCDate()).padStart(2, '0');
var hours = String(now.getUTCHours()).padStart(2, '0');
var minutes = String(now.getUTCMinutes()).padStart(2, '0');
var seconds = String(now.getUTCSeconds()).padStart(2, '0');
timeElement.textContent = year + '-' + month + '-' + day + ' ' + hours + ':' + minutes + ':' + seconds;
}
}
updateCurrentTime();
setInterval(updateCurrentTime, 1000);
</script>
</body>
</html>
-16
View File
@@ -1,16 +0,0 @@
package views
import (
_ "embed"
"net/http"
"github.com/gofiber/fiber/v2"
)
//go:embed login.html
var loginHTML []byte
func RenderLogin(c *fiber.Ctx) error {
c.Set("Content-Type", "text/html")
return c.Status(http.StatusUnauthorized).Send(loginHTML)
}