mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-04 07:56:16 -04:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f728fe83 | |||
| c65d62e762 | |||
| 736ccbb95e | |||
| 640d97b6db | |||
| 23344560f0 | |||
| 3d0af0088e | |||
| 1b87514b2f | |||
| b485b77037 | |||
| 07caa0b95d | |||
| 43c65ec7e8 | |||
| cc2d2838ca | |||
| da286065e1 | |||
| 9438d39f70 | |||
| e38f13ab8c | |||
| 2d2da7df95 | |||
| dc21ee83bc | |||
| 9b744d2bac | |||
| 4177479f82 | |||
| 34db83dc32 | |||
| 5a27c471ca | |||
| 3ecbbf3eac | |||
| d9bf193457 | |||
| 2acdc6688c | |||
| fb20d12ae1 | |||
| 7bbec7b6dd | |||
| 905ffcb185 | |||
| cb82322df2 | |||
| 56ecd1921e | |||
| a0faa14ffe | |||
| 7494a1559e | |||
| 3dad5acf1f | |||
| 99f7e3ab60 | |||
| 5b90d4c3e2 | |||
| 9406d699cc | |||
| 78defbad32 | |||
| 2efadbf6c4 | |||
| b46c300307 | |||
| d2407105a4 | |||
| 32a1a59389 | |||
| 61a89aaf58 | |||
| c5e3df41ee | |||
| cb37f80724 | |||
| bc256fbbe2 | |||
| 8014d22588 | |||
| 410ba7a467 | |||
| c56cc43552 | |||
| bc567ef7dd | |||
| f29e98bccb | |||
| 73d8304e2e | |||
| 52e8df6599 | |||
| 3828dd7ccc | |||
| cea2d9d618 | |||
| 5daf4bda8e | |||
| 508a307e8b | |||
| 8a16ad5407 | |||
| 27cc58a25f | |||
| a8decbcc85 | |||
| 1c083abc60 | |||
| 8f64d246d8 | |||
| 700047c6b9 | |||
| 419a4363c3 | |||
| 292d0c9c19 | |||
| 1147e02844 | |||
| c8e83dc4b9 |
@@ -72,7 +72,7 @@ jobs:
|
||||
suffix=
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
build-args: |
|
||||
@@ -146,7 +146,7 @@ jobs:
|
||||
suffix=
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
build-args: |
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '>=1.17.0'
|
||||
go-version: '>=1.26.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.17.0'
|
||||
go-version: '>=1.26.0'
|
||||
- run: |
|
||||
make tests-e2e
|
||||
+3
-3
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
|
||||
# Build the React UI
|
||||
RUN bun run build
|
||||
|
||||
# Use a temporary build image based on Golang 1.24-alpine
|
||||
FROM golang:1.24-alpine AS builder
|
||||
# Use a temporary build image based on Golang 1.26-alpine
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
# Define argument for linker flags
|
||||
ARG LDFLAGS="-s -w"
|
||||
@@ -61,4 +61,4 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY --from=builder /work/localagi /localagi
|
||||
|
||||
# Define the command that will be run when the container is started
|
||||
ENTRYPOINT ["/localagi"]
|
||||
ENTRYPOINT ["/localagi", "serve"]
|
||||
|
||||
@@ -18,7 +18,7 @@ Try on [ and a web browser.
|
||||
|
||||
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and 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).
|
||||
**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.
|
||||
|
||||
## 🛡️ Take Back Your Privacy
|
||||
|
||||
@@ -33,12 +33,13 @@ 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**: Powered by [LocalRecall](https://github.com/mudler/LocalRecall).
|
||||
- 📚 **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).
|
||||
- 🧠 **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.
|
||||
|
||||
@@ -107,7 +108,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 that provides persistent memory and storage capabilities for AI agents.</p>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -195,7 +196,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, LocalAGI has it all.
|
||||
- **✓ Feature-Rich**: From planning to multimodal capabilities, connectors for Slack, MCP support, built-in Skills, LocalAGI has it all.
|
||||
|
||||
## 🌟 Screenshots
|
||||
|
||||
@@ -224,6 +225,7 @@ Explore detailed documentation including:
|
||||
- [REST API Documentation](#rest-api)
|
||||
- [Connector Configuration](#connectors)
|
||||
- [Agent Configuration](#agent-configuration-reference)
|
||||
- [Skills](#3-skills)
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
@@ -237,11 +239,15 @@ 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` | LocalRecall connection |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for the app (only relevant when using an external LocalRAG URL; not used for built-in knowledge base) |
|
||||
| `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
|
||||
@@ -335,15 +341,16 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
)
|
||||
|
||||
// Create a new agent pool
|
||||
// Create a new agent pool (call pool.SetRAGProvider(...) for knowledge base; see main.go)
|
||||
pool, err := state.NewAgentPool(
|
||||
"default-model", // default model name
|
||||
"default-multimodal-model", // default multimodal model
|
||||
"image-model", // image generation model
|
||||
"transcription-model", // default transcription model
|
||||
"en", // default transcription language
|
||||
"tts-model", // default TTS model
|
||||
"http://localhost:8080", // API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
"http://localhost:8081", // LocalRAG API URL
|
||||
"your-api-key", // API key
|
||||
"./state", // state directory
|
||||
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 {
|
||||
@@ -370,8 +377,9 @@ pool, err := state.NewAgentPool(
|
||||
// Add your custom filters here
|
||||
}
|
||||
},
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
"10m", // timeout
|
||||
true, // enable conversation logs
|
||||
nil, // skills service (optional)
|
||||
)
|
||||
|
||||
// Create a new agent in the pool
|
||||
@@ -693,6 +701,16 @@ 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:
|
||||
@@ -727,7 +745,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
|
||||
export LOCALAGI_LOCALRAG_URL=http://localrecall:8080
|
||||
# Knowledge base is built-in; no separate LocalRecall service needed
|
||||
export LOCALAGI_STATE_DIR=./pool
|
||||
export LOCALAGI_TIMEOUT=5m
|
||||
export LOCALAGI_ENABLE_CONVERSATIONS_LOGGING=false
|
||||
@@ -1031,7 +1049,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` | LocalRecall connection |
|
||||
| `LOCALAGI_BASE_URL` | Optional base URL for built-in knowledge base (default `http://localhost:3000`) |
|
||||
| `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 |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
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
|
||||
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", ""),
|
||||
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
@@ -0,0 +1,32 @@
|
||||
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
@@ -0,0 +1,128 @@
|
||||
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(":3000"))
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/action"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
@@ -91,6 +92,43 @@ 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
|
||||
|
||||
+315
-88
@@ -15,6 +15,8 @@ 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"
|
||||
@@ -73,6 +75,10 @@ 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 {
|
||||
@@ -89,7 +95,7 @@ func New(opts ...Option) (*Agent, error) {
|
||||
}
|
||||
|
||||
client := llm.NewClient(options.LLMAPI.APIKey, options.LLMAPI.APIURL, options.timeout)
|
||||
llmClient := cogito.NewOpenAILLM(options.LLMAPI.Model, options.LLMAPI.APIKey, options.LLMAPI.APIURL)
|
||||
llmClient := clients.NewLocalAILLM(options.LLMAPI.Model, options.LLMAPI.APIKey, options.LLMAPI.APIURL)
|
||||
c := context.Background()
|
||||
if options.context != nil {
|
||||
c = options.context
|
||||
@@ -97,16 +103,17 @@ 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),
|
||||
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),
|
||||
}
|
||||
|
||||
// Initialize observer if provided
|
||||
@@ -173,6 +180,19 @@ 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 {
|
||||
@@ -230,6 +250,84 @@ 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 {
|
||||
@@ -238,7 +336,7 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
||||
xlog.Debug("Agent has finished", "agent", a.Character.Name)
|
||||
}()
|
||||
|
||||
if j.Obs != nil {
|
||||
if j.Obs != nil && a.observer != nil {
|
||||
if len(j.ConversationHistory) > 0 {
|
||||
m := j.ConversationHistory[len(j.ConversationHistory)-1]
|
||||
j.Obs.Creation = &types.Creation{ChatCompletionMessage: &m}
|
||||
@@ -246,14 +344,17 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
|
||||
}
|
||||
|
||||
j.Result.AddFinalizer(func(ccm []openai.ChatCompletionMessage) {
|
||||
j.Obs.Completion = &types.Completion{
|
||||
Conversation: ccm,
|
||||
if a.observer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
@@ -270,6 +371,19 @@ 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
|
||||
}
|
||||
|
||||
@@ -369,7 +483,7 @@ func (a *Agent) processPrompts(ctx context.Context, conversation Messages) Messa
|
||||
xlog.Error("Error rendering template", "error", err)
|
||||
}
|
||||
|
||||
content, err = templateExecute(promptTemplate, struct{}{})
|
||||
content, err = templateExecute(promptTemplate, CommonTemplateData{AgentName: a.Character.Name})
|
||||
if err != nil {
|
||||
xlog.Error("Error executing template", "error", err)
|
||||
content = message.Content
|
||||
@@ -428,7 +542,7 @@ func (a *Agent) processPrompts(ctx context.Context, conversation Messages) Messa
|
||||
xlog.Error("Error rendering template", "error", err)
|
||||
}
|
||||
|
||||
content, err = templateExecute(promptTemplate, struct{}{})
|
||||
content, err = templateExecute(promptTemplate, CommonTemplateData{AgentName: a.Character.Name})
|
||||
if err != nil {
|
||||
xlog.Error("Error executing template", "error", err)
|
||||
content = a.options.systemPrompt
|
||||
@@ -537,27 +651,19 @@ 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,
|
||||
Content: text + imageDesc,
|
||||
}
|
||||
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 {
|
||||
// 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)
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No image found, keep the original message
|
||||
@@ -617,7 +723,7 @@ func (a *Agent) filterJob(job *types.Job) (ok bool, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if a.Observer() != nil {
|
||||
if a.Observer() != nil && job.Obs != nil {
|
||||
obs := a.Observer().NewObservable()
|
||||
obs.Name = "filter"
|
||||
obs.Icon = "shield"
|
||||
@@ -805,6 +911,26 @@ 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
|
||||
|
||||
@@ -823,6 +949,28 @@ 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 {
|
||||
@@ -840,38 +988,46 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
// Validate builtin tools against available actions
|
||||
a.validateBuiltinTools(job)
|
||||
|
||||
fragment := cogito.NewFragment(conv...)
|
||||
|
||||
// Merge all leading system messages into one (self-eval, HUD, RAG, system prompt, custom prompts)
|
||||
var selfEvalContent, hudContent string
|
||||
if selfEvaluation {
|
||||
fragment = fragment.AddStartMessage("system", pickSelfTemplate)
|
||||
selfEvalContent = 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
|
||||
}
|
||||
fragment = fragment.AddStartMessage("system", prompt)
|
||||
hudContent = 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 {
|
||||
@@ -882,14 +1038,37 @@ 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{
|
||||
@@ -906,28 +1085,23 @@ 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) {
|
||||
if a.observer != nil && obs != nil {
|
||||
obs := observables[t.ToolArguments.ID]
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
toolObs := observables[t.ToolArguments.ID]
|
||||
if a.observer != nil && toolObs != nil {
|
||||
toolObs.Progress = append(toolObs.Progress, types.Progress{
|
||||
ActionResult: t.Result,
|
||||
})
|
||||
obs.Name = "action"
|
||||
obs.Icon = "bolt"
|
||||
obs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*obs)
|
||||
toolObs.Name = "action"
|
||||
toolObs.Icon = "bolt"
|
||||
toolObs.MakeLastProgressCompletion()
|
||||
a.observer.Update(*toolObs)
|
||||
}
|
||||
|
||||
// Use full ActionResult (including Metadata) from action result,
|
||||
@@ -986,6 +1160,19 @@ 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"
|
||||
@@ -1014,7 +1201,8 @@ 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)
|
||||
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
|
||||
finishedByCallback = true
|
||||
finishErr = fmt.Errorf("error unmarshalling conversation response: %w", err)
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
}
|
||||
@@ -1040,22 +1228,24 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
msg,
|
||||
}
|
||||
job.Result.SetResponse("decided to initiate a new conversation")
|
||||
job.Result.Finish(nil)
|
||||
finishedByCallback = true
|
||||
finishErr = nil
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: true,
|
||||
Approved: false,
|
||||
}
|
||||
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 obs != nil && a.observer != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Completion = &types.Completion{
|
||||
Error: werr.Error(),
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
a.observer.Update(*stateObs)
|
||||
}
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: false,
|
||||
@@ -1063,21 +1253,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 obs != nil && a.observer != nil {
|
||||
obs.Progress = append(obs.Progress, types.Progress{
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Progress = append(stateObs.Progress, types.Progress{
|
||||
AgentState: &state,
|
||||
})
|
||||
a.observer.Update(*obs)
|
||||
a.observer.Update(*stateObs)
|
||||
}
|
||||
|
||||
// update the state file
|
||||
if a.options.statefile != "" {
|
||||
if err := a.SaveState(a.options.statefile); err != nil {
|
||||
if obs != nil && a.observer != nil {
|
||||
obs.Completion = &types.Completion{
|
||||
if stateObs != nil && a.observer != nil {
|
||||
stateObs.Completion = &types.Completion{
|
||||
Error: err.Error(),
|
||||
}
|
||||
a.observer.Update(*obs)
|
||||
a.observer.Update(*stateObs)
|
||||
}
|
||||
|
||||
return cogito.ToolCallDecision{
|
||||
@@ -1085,6 +1275,11 @@ 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1107,8 +1302,8 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
})
|
||||
|
||||
job.Result.Conversation = conv
|
||||
job.Result.Finish(nil)
|
||||
|
||||
finishedByCallback = true
|
||||
finishErr = nil
|
||||
}
|
||||
return cogito.ToolCallDecision{
|
||||
Approved: cont,
|
||||
@@ -1123,11 +1318,12 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
cogitoOpts = append(cogitoOpts, cogito.EnableAutoPlanReEvaluator)
|
||||
}
|
||||
if a.options.LLMAPI.ReviewerModel != "" {
|
||||
llmClient := cogito.NewOpenAILLM(a.options.LLMAPI.ReviewerModel, a.options.LLMAPI.APIKey, a.options.LLMAPI.APIURL)
|
||||
llmClient := clients.NewLocalAILLM(a.options.LLMAPI.ReviewerModel, a.options.LLMAPI.APIKey, a.options.LLMAPI.APIURL)
|
||||
cogitoOpts = append(cogitoOpts, cogito.WithReviewerLLM(llmClient))
|
||||
}
|
||||
}
|
||||
|
||||
// Important: DisableSinkState must be before WithForceReasoning()
|
||||
if a.options.disableSinkState {
|
||||
cogitoOpts = append(cogitoOpts, cogito.DisableSinkState)
|
||||
}
|
||||
@@ -1142,11 +1338,33 @@ 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...,
|
||||
@@ -1164,6 +1382,11 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
if finishedByCallback {
|
||||
job.Result.Finish(finishErr)
|
||||
return
|
||||
}
|
||||
|
||||
if userTool {
|
||||
return
|
||||
}
|
||||
@@ -1235,8 +1458,12 @@ 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(innerMonologueTemplate),
|
||||
types.WithText(innerMonologue),
|
||||
types.WithReasoningCallback(a.options.reasoningCallback),
|
||||
types.WithResultCallback(a.options.resultCallback),
|
||||
)
|
||||
|
||||
+23
-2
@@ -211,13 +211,34 @@ 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() {
|
||||
for _, s := range a.mcpSessions {
|
||||
s.Close()
|
||||
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()
|
||||
}
|
||||
}
|
||||
a.mcpSessions = keep
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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))
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,11 @@ 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
|
||||
@@ -32,7 +37,7 @@ func NewSSEObserver(agent string, manager sse.Manager) *SSEObserver {
|
||||
agent: agent,
|
||||
maxID: 1,
|
||||
manager: manager,
|
||||
history: make([]types.Observable, 100),
|
||||
history: make([]types.Observable, historyRingSize),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +97,6 @@ func (s *SSEObserver) ClearHistory() {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.history = make([]types.Observable, 100)
|
||||
s.history = make([]types.Observable, historyRingSize)
|
||||
s.historyLast = 0
|
||||
}
|
||||
|
||||
+104
-2
@@ -5,7 +5,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/cogito"
|
||||
)
|
||||
|
||||
type Option func(*options) error
|
||||
@@ -48,6 +50,7 @@ type options struct {
|
||||
canStopItself bool
|
||||
initiateConversations bool
|
||||
forceReasoning bool
|
||||
forceReasoningTool bool
|
||||
enableGuidedTools bool
|
||||
canPlan bool
|
||||
disableSinkState bool
|
||||
@@ -64,11 +67,15 @@ type options struct {
|
||||
|
||||
// Evaluation settings
|
||||
maxEvaluationLoops int
|
||||
loopDetection int
|
||||
enableEvaluation bool
|
||||
|
||||
prompts []DynamicPrompt
|
||||
|
||||
systemPrompt string
|
||||
systemPrompt string
|
||||
innerMonologueTemplate string
|
||||
skillPromptTemplate string
|
||||
schedulerTaskTemplate string
|
||||
|
||||
// callbacks
|
||||
reasoningCallback func(types.ActionCurrentState) bool
|
||||
@@ -79,12 +86,24 @@ type options struct {
|
||||
mcpServers []MCPServer
|
||||
mcpStdioServers []MCPSTDIOServer
|
||||
mcpPrepareScript string
|
||||
extraMCPSessions []*mcp.ClientSession
|
||||
newConversationsSubscribers []func(*types.ConversationMessage)
|
||||
|
||||
observer Observer
|
||||
observer Observer
|
||||
enableAutoCompaction bool
|
||||
autoCompactionThreshold int
|
||||
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 {
|
||||
@@ -94,6 +113,7 @@ func (o *options) SeparatedMultimodalModel() bool {
|
||||
func defaultOptions() *options {
|
||||
return &options{
|
||||
parallelJobs: 1,
|
||||
maxAttempts: 1,
|
||||
periodicRuns: 15 * time.Minute,
|
||||
schedulerPollInterval: 30 * time.Second,
|
||||
maxEvaluationLoops: 2,
|
||||
@@ -142,6 +162,11 @@ 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
|
||||
@@ -193,6 +218,29 @@ 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)
|
||||
@@ -271,6 +319,22 @@ 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
|
||||
@@ -323,6 +387,14 @@ 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
|
||||
@@ -513,3 +585,33 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,30 @@ 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) {
|
||||
// Create a job for the reminder
|
||||
// Render the scheduler task template - if custom template is set, it will include {{.Task}}
|
||||
// If no custom scheduler template is set, fall back to default inner monologue template
|
||||
innerMonologue := fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)
|
||||
|
||||
if e.agent.options.schedulerTaskTemplate != "" {
|
||||
tmpl, err := templateBase("taskTemplate", e.agent.options.schedulerTaskTemplate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
|
||||
innerMonologue, err = templateExecute(tmpl, &InnerMonologueTemplateData{
|
||||
CommonTemplateData: CommonTemplateData{
|
||||
AgentName: agentName,
|
||||
},
|
||||
Task: prompt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render scheduler task template: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a job for the reminder with the rendered inner monologue
|
||||
reminderJob := types.NewJob(
|
||||
types.WithText(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.WithText(innerMonologue),
|
||||
types.WithReasoningCallback(e.agent.options.reasoningCallback),
|
||||
types.WithResultCallback(e.agent.options.resultCallback),
|
||||
types.WithContext(ctx),
|
||||
|
||||
@@ -10,6 +10,15 @@ 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)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ type (
|
||||
Manager interface {
|
||||
Send(message Envelope)
|
||||
Handle(ctx *fiber.Ctx, cl Listener)
|
||||
Register(cl Listener)
|
||||
Unregister(id string)
|
||||
Clients() []string
|
||||
}
|
||||
|
||||
@@ -110,6 +112,17 @@ 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) {
|
||||
|
||||
|
||||
@@ -15,6 +15,34 @@ 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})`)
|
||||
|
||||
@@ -102,9 +130,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 *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
func RunCompaction(ctx context.Context, client KBCompactionClient, period string, summarize bool, apiURL, apiKey, model string) error {
|
||||
collection := client.Collection()
|
||||
entries, err := client.Client.ListEntries(collection)
|
||||
entries, err := client.ListEntries()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list entries: %w", err)
|
||||
}
|
||||
@@ -164,7 +192,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s
|
||||
xlog.Warn("compaction: write temp file failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if err := client.Client.Store(collection, tmpPath); err != nil {
|
||||
if err := client.Store(tmpPath); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
xlog.Warn("compaction: store failed", "key", key, "error", err)
|
||||
continue
|
||||
@@ -172,7 +200,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s
|
||||
os.RemoveAll(tmpDir)
|
||||
|
||||
for _, entry := range groupEntries {
|
||||
if _, err := client.Client.DeleteEntry(collection, entry); err != nil {
|
||||
if err := client.DeleteEntry(entry); err != nil {
|
||||
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -182,7 +210,7 @@ func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period s
|
||||
}
|
||||
|
||||
// runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done.
|
||||
func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) {
|
||||
func runCompactionTicker(ctx context.Context, client KBCompactionClient, config *AgentConfig, apiURL, apiKey, model string) {
|
||||
// Run first compaction immediately on startup
|
||||
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
|
||||
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
|
||||
|
||||
+133
-34
@@ -74,36 +74,46 @@ 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"`
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AgentConfigMeta struct {
|
||||
@@ -310,8 +320,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",
|
||||
@@ -329,6 +339,30 @@ 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",
|
||||
@@ -353,6 +387,24 @@ 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",
|
||||
@@ -383,10 +435,18 @@ func NewAgentConfigMeta(
|
||||
Name: "enable_reasoning",
|
||||
Label: "Enable Reasoning",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
DefaultValue: false,
|
||||
HelpText: "Enable agent to explain its reasoning process",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_reasoning_tool",
|
||||
Label: "Enable Reasoning for tools",
|
||||
Type: "checkbox",
|
||||
DefaultValue: true,
|
||||
HelpText: "Enable agent to reason more on tools",
|
||||
Tags: config.Tags{Section: "AdvancedSettings"},
|
||||
},
|
||||
{
|
||||
Name: "enable_guided_tools",
|
||||
Label: "Enable Guided Tools",
|
||||
@@ -395,6 +455,14 @@ 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",
|
||||
@@ -437,6 +505,24 @@ 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",
|
||||
@@ -455,6 +541,16 @@ 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",
|
||||
@@ -499,7 +595,8 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
|
||||
*Alias
|
||||
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
|
||||
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
MaxAttempts interface{} `json:"max_attempts"`
|
||||
ParallelJobs interface{} `json:"parallel_jobs"`
|
||||
KnowledgeBaseResults interface{} `json:"kb_results"`
|
||||
}{
|
||||
Alias: (*Alias)(a),
|
||||
@@ -511,8 +608,10 @@ 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 {
|
||||
|
||||
+317
-61
@@ -12,31 +12,68 @@ import (
|
||||
"time"
|
||||
|
||||
. "github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/sse"
|
||||
sseLib "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]sse.Manager
|
||||
managers map[string]sseLib.Manager
|
||||
agentStatus map[string]*Status
|
||||
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
|
||||
defaultTranscriptionModel, defaultTranscriptionLanguage string
|
||||
localRAGAPI, localRAGKey, apiKey string
|
||||
apiKey string
|
||||
ragProvider RAGProvider
|
||||
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 {
|
||||
@@ -71,13 +108,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.
|
||||
@@ -99,24 +136,34 @@ 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]sse.Manager),
|
||||
managers: make(map[string]sseLib.Manager),
|
||||
connectors: connectors,
|
||||
availableActions: availableActions,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
poolData, err := loadPoolFromFile(poolfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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 &AgentPool{
|
||||
file: poolfile,
|
||||
@@ -129,16 +176,16 @@ func NewAgentPool(
|
||||
defaultTTSModel: defaultTTSModel,
|
||||
apiKey: apiKey,
|
||||
agents: make(map[string]*Agent),
|
||||
managers: make(map[string]sse.Manager),
|
||||
managers: make(map[string]sseLib.Manager),
|
||||
agentStatus: map[string]*Status{},
|
||||
pool: *poolData,
|
||||
connectors: connectors,
|
||||
localRAGAPI: LocalRAGAPI,
|
||||
dynamicPrompt: promptBlocks,
|
||||
filters: filters,
|
||||
availableActions: availableActions,
|
||||
timeout: timeout,
|
||||
conversationLogs: conversationPath,
|
||||
skillsService: skillsService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -147,6 +194,14 @@ 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.
|
||||
@@ -172,22 +227,22 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
|
||||
|
||||
oldAgent := a.agents[name]
|
||||
var o *types.Observable
|
||||
obs := oldAgent.Observer()
|
||||
if obs != nil {
|
||||
o = obs.NewObservable()
|
||||
o.Name = "Restarting Agent"
|
||||
o.Icon = "sync"
|
||||
o.Creation = &types.Creation{}
|
||||
obs.Update(*o)
|
||||
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()
|
||||
}
|
||||
|
||||
stateFile, characterFile := a.stateFiles(name)
|
||||
|
||||
os.Remove(stateFile)
|
||||
os.Remove(characterFile)
|
||||
|
||||
oldAgent.Stop()
|
||||
|
||||
a.pool[name] = *agentConfig
|
||||
delete(a.agents, name)
|
||||
|
||||
@@ -237,11 +292,11 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
|
||||
}
|
||||
|
||||
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
|
||||
var manager sse.Manager
|
||||
var manager sseLib.Manager
|
||||
if m, ok := a.managers[name]; ok {
|
||||
manager = m
|
||||
} else {
|
||||
manager = sse.NewManager(5)
|
||||
manager = sseLib.NewManager(5)
|
||||
}
|
||||
ctx := context.Background()
|
||||
model := a.defaultModel
|
||||
@@ -279,29 +334,29 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
config.SchedulerPollInterval = "30s"
|
||||
}
|
||||
|
||||
// XXX: Why do we update the pool config from an Agent's config?
|
||||
// Use agent-specific config when set, otherwise pool defaults. Do not update pool from agent config.
|
||||
effectiveAPIURL := a.apiURL
|
||||
if config.APIURL != "" {
|
||||
a.apiURL = config.APIURL
|
||||
effectiveAPIURL = config.APIURL
|
||||
} else {
|
||||
config.APIURL = a.apiURL
|
||||
}
|
||||
|
||||
effectiveAPIKey := a.apiKey
|
||||
if config.APIKey != "" {
|
||||
a.apiKey = config.APIKey
|
||||
effectiveAPIKey = config.APIKey
|
||||
} else {
|
||||
config.APIKey = a.apiKey
|
||||
}
|
||||
|
||||
if config.LocalRAGURL != "" {
|
||||
a.localRAGAPI = config.LocalRAGURL
|
||||
}
|
||||
|
||||
if config.LocalRAGAPIKey != "" {
|
||||
a.localRAGKey = config.LocalRAGAPIKey
|
||||
}
|
||||
effectiveLocalRAGAPI := config.LocalRAGURL
|
||||
effectiveLocalRAGKey := 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)
|
||||
@@ -325,7 +380,7 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
"Creating agent",
|
||||
"name", name,
|
||||
"model", model,
|
||||
"api_url", a.apiURL,
|
||||
"api_url", effectiveAPIURL,
|
||||
"actions", actionsLog,
|
||||
"connectors", connectorLog,
|
||||
"filters", filtersLog,
|
||||
@@ -343,7 +398,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(a.apiURL),
|
||||
WithLLMAPIURL(effectiveAPIURL),
|
||||
WithContext(ctx),
|
||||
WithMCPServers(config.MCPServers...),
|
||||
WithTranscriptionModel(transcriptionModel),
|
||||
@@ -365,19 +420,23 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
),
|
||||
WithStateFile(stateFile),
|
||||
WithCharacterFile(characterFile),
|
||||
WithLLMAPIKey(a.apiKey),
|
||||
WithLLMAPIKey(effectiveAPIKey),
|
||||
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", state.Action.Definition().Name,
|
||||
"action", actionName,
|
||||
"params", state.Params,
|
||||
)
|
||||
|
||||
manager.Send(
|
||||
sse.NewMessage(
|
||||
sseLib.NewMessage(
|
||||
fmt.Sprintf(`Thinking: %s`, utils.HTMLify(state.Reasoning)),
|
||||
).WithEvent("status"),
|
||||
)
|
||||
@@ -390,6 +449,8 @@ 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) {
|
||||
@@ -404,16 +465,20 @@ 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,
|
||||
state.ActionCurrentState.Action.Definition().Name,
|
||||
actionName,
|
||||
state.ActionCurrentState.Params,
|
||||
state.Result)
|
||||
manager.Send(
|
||||
sse.NewMessage(
|
||||
sseLib.NewMessage(
|
||||
utils.HTMLify(
|
||||
text,
|
||||
),
|
||||
@@ -479,26 +544,33 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if a.skillsService != nil && config.EnableSkills {
|
||||
if session, err := a.skillsService.GetMCPSession(ctx); err == nil && session != nil {
|
||||
opts = append(opts, WithMCPSession(session))
|
||||
}
|
||||
}
|
||||
|
||||
var ragDB RAGDB
|
||||
var compactionClient KBCompactionClient
|
||||
if config.EnableKnowledgeBase && a.ragProvider != nil {
|
||||
if db, comp, ok := a.ragProvider(name, effectiveLocalRAGAPI, effectiveLocalRAGKey); ok && db != nil {
|
||||
ragDB = db
|
||||
compactionClient = comp
|
||||
}
|
||||
}
|
||||
if ragDB != nil {
|
||||
opts = append(opts, WithRAGDB(ragDB), EnableKnowledgeBase)
|
||||
kbAutoSearch := config.KBAutoSearch
|
||||
if !config.KBAutoSearch && !config.KBAsTools {
|
||||
// Both new fields are false, likely an old config - default to true for backward compatibility
|
||||
kbAutoSearch = true
|
||||
}
|
||||
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
|
||||
// Inject KB wrapper actions if enabled
|
||||
if config.KBAsTools && ragClient != nil {
|
||||
if config.KBAsTools {
|
||||
kbResults := config.KnowledgeBaseResults
|
||||
if kbResults <= 0 {
|
||||
kbResults = 5 // Default
|
||||
kbResults = 5
|
||||
}
|
||||
searchAction, addAction := NewKBWrapperActions(ragClient, kbResults)
|
||||
searchAction, addAction := NewKBWrapperActions(ragDB, kbResults)
|
||||
opts = append(opts, WithActions(searchAction, addAction))
|
||||
}
|
||||
}
|
||||
@@ -515,6 +587,14 @@ 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))
|
||||
}
|
||||
@@ -523,6 +603,12 @@ 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())
|
||||
}
|
||||
@@ -531,6 +617,52 @@ 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...)
|
||||
@@ -547,8 +679,8 @@ func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConf
|
||||
}
|
||||
}()
|
||||
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil {
|
||||
go runCompactionTicker(ctx, ragClient, config, a.apiURL, a.apiKey, model)
|
||||
if config.EnableKnowledgeBase && config.EnableKBCompaction && compactionClient != nil {
|
||||
go runCompactionTicker(ctx, compactionClient, config, effectiveAPIURL, effectiveAPIKey, model)
|
||||
}
|
||||
|
||||
xlog.Info("Starting connectors", "name", name, "config", config)
|
||||
@@ -560,7 +692,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(sse.NewMessage(
|
||||
manager.Send(sseLib.NewMessage(
|
||||
utils.HTMLify(agent.State().String()),
|
||||
).WithEvent("hud"))
|
||||
}
|
||||
@@ -623,6 +755,115 @@ 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))
|
||||
@@ -660,7 +901,21 @@ func (a *AgentPool) save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(a.file, data, 0644)
|
||||
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
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetAgent(name string) *Agent {
|
||||
@@ -689,8 +944,9 @@ func (a *AgentPool) GetConfig(name string) *AgentConfig {
|
||||
return &agent
|
||||
}
|
||||
|
||||
func (a *AgentPool) GetManager(name string) sse.Manager {
|
||||
func (a *AgentPool) GetManager(name string) sseLib.Manager {
|
||||
a.Lock()
|
||||
defer a.Unlock()
|
||||
return a.managers[name]
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ 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
|
||||
|
||||
@@ -31,7 +31,7 @@ func (j *JobResult) SetResult(text ActionState) {
|
||||
j.State = append(j.State, text)
|
||||
}
|
||||
|
||||
// SetResult sets the result of a job
|
||||
// Finish marks the job as done and closes the ready channel.
|
||||
func (j *JobResult) Finish(e error) {
|
||||
j.Lock()
|
||||
j.Error = e
|
||||
|
||||
+5
-15
@@ -11,26 +11,16 @@ 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
|
||||
|
||||
@@ -12,26 +12,16 @@ 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
|
||||
|
||||
@@ -17,26 +17,16 @@ 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
|
||||
|
||||
+9
-33
@@ -25,7 +25,7 @@ services:
|
||||
- backends:/backends
|
||||
- images:/tmp/generated/images
|
||||
|
||||
localrecall-postgres:
|
||||
postgres:
|
||||
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
|
||||
environment:
|
||||
- POSTGRES_DB=localrecall
|
||||
@@ -41,34 +41,6 @@ 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: .
|
||||
@@ -101,8 +73,8 @@ services:
|
||||
depends_on:
|
||||
localai:
|
||||
condition: service_healthy
|
||||
localrecall-healthcheck:
|
||||
condition: service_completed_successfully
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
dind:
|
||||
condition: service_healthy
|
||||
build:
|
||||
@@ -116,8 +88,11 @@ 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
|
||||
@@ -126,11 +101,12 @@ 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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/mudler/LocalAGI
|
||||
|
||||
go 1.24.4
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
@@ -11,24 +11,28 @@ 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.9
|
||||
github.com/gofiber/fiber/v2 v2.52.11
|
||||
github.com/gofiber/template/html/v2 v2.1.3
|
||||
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.1.0
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc
|
||||
github.com/mudler/xlog v0.0.1
|
||||
github.com/onsi/ginkgo/v2 v2.25.3
|
||||
github.com/onsi/gomega v1.38.2
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
github.com/mudler/cogito v0.9.5-0.20260315222927-63abdec7189b
|
||||
github.com/mudler/localrecall v0.5.9-0.20260321005011-810084e9369b
|
||||
github.com/mudler/skillserver v0.0.5-0.20260221145827-0639a82c8f49
|
||||
github.com/mudler/xlog v0.0.5
|
||||
github.com/onsi/ginkgo/v2 v2.27.5
|
||||
github.com/onsi/gomega v1.39.0
|
||||
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.43.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056
|
||||
maunium.net/go/mautrix v0.17.0
|
||||
mvdan.cc/xurls/v2 v2.6.0
|
||||
@@ -39,6 +43,8 @@ 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
|
||||
@@ -58,22 +64,48 @@ 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/dslipak/pdf v0.0.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // 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/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.31.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -118,12 +150,11 @@ 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.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
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
maunium.net/go/maulogger/v2 v2.4.1 // indirect
|
||||
|
||||
@@ -12,8 +12,11 @@ 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=
|
||||
@@ -22,6 +25,8 @@ 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=
|
||||
@@ -29,6 +34,8 @@ 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=
|
||||
@@ -76,6 +83,8 @@ 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=
|
||||
@@ -87,6 +96,9 @@ 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=
|
||||
@@ -105,8 +117,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dslipak/pdf v0.0.2 h1:djAvcM5neg9Ush+zR6QXB+VMJzR6TdnX766HPIg1JmI=
|
||||
github.com/dslipak/pdf v0.0.2/go.mod h1:2L3SnkI9cQwnAS9gfPz2iUoLC0rUZwbucpbKi5R1mUo=
|
||||
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=
|
||||
@@ -115,12 +131,30 @@ 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/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=
|
||||
@@ -135,13 +169,23 @@ 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.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
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/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=
|
||||
@@ -174,6 +218,20 @@ 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/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=
|
||||
@@ -181,16 +239,23 @@ 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/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=
|
||||
@@ -201,6 +266,8 @@ 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=
|
||||
@@ -219,8 +286,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.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
|
||||
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s=
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -230,29 +297,31 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b h1:LXHovZzNgP0n/oYEoO4zDt4k4CRvG0Owhu8x/OVGhYc=
|
||||
github.com/mudler/cogito v0.8.2-0.20260206153401-a5346975d42b/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44 h1:joGszpItINnZdoL/0p2077Wz2xnxMGRSRgYN5mS7I4c=
|
||||
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561 h1:qA7dGJhF5GjgGKHh0lOITZjl9q2jehjKqxxCnaUR1yg=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215213413-af34921ff561/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc h1:tBAGwQq5kOSIh+vfLffVr5Th2ajFwrTj0usLgyGM2CQ=
|
||||
github.com/mudler/cogito v0.8.2-0.20260215230740-d1c0dc9bd9dc/go.mod h1:6sfja3lcu2nWRzEc0wwqGNu/eCG3EWgij+8s7xyUeQ4=
|
||||
github.com/mudler/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
|
||||
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
|
||||
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.5.9-0.20260321005011-810084e9369b h1:XeAnOEOOSKMfS5XNGpRTltQgjKCinho0V4uAhrgxN7Q=
|
||||
github.com/mudler/localrecall v0.5.9-0.20260321005011-810084e9369b/go.mod h1:xuPtgL9zUyiQLmspYzO3kaboYrGbWmwi8BQPt1aCAcs=
|
||||
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/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
|
||||
github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
|
||||
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
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=
|
||||
@@ -263,18 +332,17 @@ 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.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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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=
|
||||
@@ -288,12 +356,19 @@ 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=
|
||||
@@ -301,6 +376,7 @@ 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
@@ -332,6 +408,8 @@ 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=
|
||||
@@ -357,19 +435,18 @@ 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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
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=
|
||||
@@ -378,9 +455,12 @@ 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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
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=
|
||||
@@ -388,8 +468,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
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=
|
||||
@@ -399,13 +479,16 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.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=
|
||||
@@ -415,8 +498,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.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=
|
||||
@@ -426,8 +509,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.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
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=
|
||||
@@ -439,16 +522,16 @@ 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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
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=
|
||||
@@ -458,8 +541,12 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/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/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=
|
||||
|
||||
@@ -1,107 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/services"
|
||||
"github.com/mudler/LocalAGI/webui"
|
||||
"github.com/mudler/LocalAGI/cmd"
|
||||
)
|
||||
|
||||
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"))
|
||||
cmd.Execute()
|
||||
}
|
||||
|
||||
+125
-10
@@ -93,7 +93,8 @@ func (c *WrappedClient) Store(s string) error {
|
||||
}
|
||||
|
||||
defer os.Remove(f)
|
||||
return c.Client.Store(c.collection, f)
|
||||
_, err = c.Client.Store(c.collection, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
|
||||
@@ -283,10 +284,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -467,13 +472,13 @@ func (c *Client) Reset(collection string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store uploads a file to a collection
|
||||
func (c *Client) Store(collection, filePath string) error {
|
||||
// Store uploads a file to a collection and returns the assigned entry key.
|
||||
func (c *Client) Store(collection, filePath string) (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()
|
||||
|
||||
@@ -482,22 +487,22 @@ func (c *Client) Store(collection, filePath 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)
|
||||
@@ -505,14 +510,124 @@ func (c *Client) Store(collection, filePath 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
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ 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"
|
||||
@@ -83,17 +85,24 @@ 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, result.Title, "", "", false)
|
||||
pdf.MultiCell(0, 10, tr(result.Title), "", "", false)
|
||||
pdf.Ln(5)
|
||||
}
|
||||
|
||||
// Add content
|
||||
// Add content: parse as markdown and render, or fall back to plain text
|
||||
pdf.SetFont("Arial", "", 12)
|
||||
pdf.MultiCell(0, 10, result.Content, "", "", false)
|
||||
p := parser.NewWithExtensions(parser.CommonExtensions)
|
||||
doc := p.Parse([]byte(result.Content))
|
||||
if doc != nil && ast.GetFirstChild(doc) != nil {
|
||||
renderMarkdownToPDF(pdf, tr, doc)
|
||||
} else {
|
||||
pdf.MultiCell(0, 10, tr(result.Content), "", "", false)
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
savedPath := filepath.Join(a.outputDir, filename)
|
||||
@@ -120,7 +129,7 @@ func (a *GenPDFAction) Definition() types.ActionDefinition {
|
||||
},
|
||||
"content": {
|
||||
Type: jsonschema.String,
|
||||
Description: "Text content to include in the PDF document",
|
||||
Description: "Text or Markdown content to include in the PDF (headings, bold, lists, code blocks, etc. are rendered)",
|
||||
},
|
||||
"filename": {
|
||||
Type: jsonschema.String,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
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, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,4 +161,52 @@ 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\", 2–3"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
|
||||
It("generates PDF with markdown table", func() {
|
||||
content := "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
||||
result, err := action.Run(ctx, sharedState, types.ActionParams{
|
||||
"content": content,
|
||||
})
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
|
||||
paths := result.Metadata[actions.MetadataPDFs].([]string)
|
||||
Expect(paths).To(HaveLen(1))
|
||||
Expect(paths[0]).To(BeAnExistingFile())
|
||||
info, err := os.Stat(paths[0])
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(info.Size()).To(BeNumerically(">", 0))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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()
|
||||
}
|
||||
@@ -93,25 +93,6 @@ 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
|
||||
@@ -136,9 +117,6 @@ 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
|
||||
@@ -159,9 +137,10 @@ func (m *Matrix) handleRoomMessage(a *agent.Agent, evt *event.Event) {
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add room to metadata for tracking
|
||||
// Add room and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]any{
|
||||
"room": evt.RoomID.String(),
|
||||
"room": evt.RoomID.String(),
|
||||
types.MetadataKeyConversationID: "matrix:" + evt.RoomID.String(),
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
|
||||
+302
-174
@@ -4,7 +4,9 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -18,6 +20,7 @@ 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"
|
||||
|
||||
@@ -36,6 +39,7 @@ 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
|
||||
@@ -53,15 +57,39 @@ 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) {
|
||||
// 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 != "" {
|
||||
// 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()
|
||||
t.activeJobsMutex.Lock()
|
||||
delete(t.activeJobs, channel)
|
||||
t.activeJobsMutex.Unlock()
|
||||
@@ -73,7 +101,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.RLock()
|
||||
t.placeholderMutex.Lock()
|
||||
msgTs, exists := t.placeholders[state.Job.UUID]
|
||||
channel := ""
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -81,18 +109,31 @@ func (t *Slack) AgentReasoningCallback() func(state types.ActionCurrentState) bo
|
||||
channel = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgTs == "" || channel == "" || t.apiClient == nil {
|
||||
return true // Skip if we don't have a message to update
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
thought := thinkingMessage + "\n\n"
|
||||
// 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
|
||||
}
|
||||
if state.Reasoning != "" {
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
acc.AppendReasoning(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,
|
||||
@@ -105,25 +146,6 @@ 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+">", "")
|
||||
@@ -149,116 +171,193 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
|
||||
return message
|
||||
}
|
||||
|
||||
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
|
||||
for _, state := range j.State {
|
||||
// coming from the browser agent
|
||||
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
|
||||
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
|
||||
// state := historyStruct.States[len(historyStruct.States)-1]
|
||||
// // Decode base64 screenshot and upload to Slack
|
||||
// if state.Screenshot != "" {
|
||||
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
|
||||
// if err != nil {
|
||||
// xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
|
||||
// continue
|
||||
// }
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
xlog.Error("Error reading song file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
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
|
||||
for _, state := range j.State {
|
||||
out = append(out, attachmentsFromMetadataOnly(state.Metadata)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// ImageData represents a single image with its metadata
|
||||
@@ -433,9 +532,6 @@ 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))
|
||||
@@ -464,9 +560,10 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
agentOptions = append(agentOptions, types.WithConversationHistory(currentConv))
|
||||
|
||||
// Add channel to metadata for tracking
|
||||
// Add channel and conversation_id for tracking and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
}
|
||||
agentOptions = append(agentOptions, types.WithMetadata(metadata))
|
||||
|
||||
@@ -514,50 +611,61 @@ func (t *Slack) handleChannelMessage(
|
||||
|
||||
xlog.Debug("After adding message to conversation tracker", "conversation", a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("slack:%s", t.channelID)))
|
||||
|
||||
//res.Response = githubmarkdownconvertergo.Slack(res.Response)
|
||||
|
||||
replyWithPostMessage(res.Response, api, ev, postMessageParams, res)
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
replyWithPostMessage(convertedResponse, 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 in multiple messages, and update the first
|
||||
|
||||
// Split response into multiple messages; post first to get thread ts, upload files once, then post rest in thread
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
|
||||
for _, message := range messages {
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
var firstTs string
|
||||
for i, message := range messages {
|
||||
opts := []slack.MsgOption{
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
)
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
}
|
||||
if i > 0 && firstTs != "" {
|
||||
opts = append(opts, slack.MsgOptionTS(firstTs))
|
||||
}
|
||||
_, ts, err := api.PostMessage(ev.Channel, opts...)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
firstTs = ts
|
||||
uploadJobResultFiles(res, api, ev.Channel, firstTs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, _, err := api.PostMessage(ev.Channel,
|
||||
_, ts, err := api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(res.Response, true),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, "")...),
|
||||
// slack.MsgOptionTS(ts),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
return
|
||||
}
|
||||
uploadJobResultFiles(res, api, ev.Channel, ts)
|
||||
}
|
||||
}
|
||||
|
||||
func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, res *types.JobResult) {
|
||||
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)
|
||||
if len(finalResponse) > 3000 {
|
||||
// split response in multiple messages, and update the first
|
||||
|
||||
messages := xstrings.SplitParagraph(finalResponse, 3000)
|
||||
|
||||
_, _, _, err := api.UpdateMessage(
|
||||
@@ -565,11 +673,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(messages[0], true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
slack.MsgOptionText(messages[0], false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
}
|
||||
|
||||
for i, message := range messages {
|
||||
@@ -579,11 +688,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
_, _, err = api.PostMessage(ev.Channel,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(message, true),
|
||||
slack.MsgOptionText(message, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionTS(ts),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
|
||||
xlog.Error("Error posting message", "error", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -592,11 +702,12 @@ func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackeven
|
||||
msgTs,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(finalResponse, true),
|
||||
slack.MsgOptionAttachments(generateAttachmentsFromJobResponse(res, api, ev.Channel, msgTs)...),
|
||||
slack.MsgOptionText(finalResponse, false),
|
||||
slack.MsgOptionPostMessageParameters(postMessageParams),
|
||||
slack.MsgOptionAttachments(attachments...),
|
||||
)
|
||||
if err != nil {
|
||||
xlog.Error(fmt.Sprintf("Error updating final message: %v", err))
|
||||
xlog.Error("Error updating final message", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -728,9 +839,10 @@ func (t *Slack) handleMention(
|
||||
}
|
||||
}
|
||||
|
||||
// Add channel to job metadata for use in callbacks
|
||||
// Add channel and conversation_id for callbacks and cancel-previous-on-new-message
|
||||
metadata := map[string]interface{}{
|
||||
"channel": ev.Channel,
|
||||
"channel": ev.Channel,
|
||||
types.MetadataKeyConversationID: "slack:" + ev.Channel,
|
||||
}
|
||||
|
||||
// Call the agent with the conversation history
|
||||
@@ -740,9 +852,9 @@ func (t *Slack) handleMention(
|
||||
types.WithMetadata(metadata),
|
||||
)
|
||||
|
||||
if res.Response == "" {
|
||||
if res == nil || res.Response == "" {
|
||||
xlog.Debug(fmt.Sprintf("Empty response from agent"))
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, res)
|
||||
replyToUpdateMessage("there was an internal error. try again!", api, ev, msgTs, ts, postMessageParams, res)
|
||||
|
||||
// _, _, err := api.DeleteMessage(ev.Channel, msgTs)
|
||||
// if err != nil {
|
||||
@@ -753,20 +865,24 @@ 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
|
||||
//finalResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", user.Name, res.Response)
|
||||
// Format the final response (convert GitHub markdown to Slack mrkdwn)
|
||||
convertedResponse := githubmarkdownconvertergo.Slack(res.Response)
|
||||
finalResponse := fmt.Sprintf("@%s %s", displayName, convertedResponse)
|
||||
xlog.Debug("Send final response to slack", "response", finalResponse)
|
||||
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, res)
|
||||
replyToUpdateMessage(finalResponse, api, ev, msgTs, ts, postMessageParams, res)
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
}
|
||||
@@ -787,18 +903,30 @@ 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
|
||||
// handle new conversations (e.g. send_message / newconversation action)
|
||||
// Preserve metadata (PDFs, songs, images, URLs) so attachments are not lost
|
||||
a.AddSubscriber(func(ccm *types.ConversationMessage) {
|
||||
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
|
||||
_, _, err := api.PostMessage(t.channelID,
|
||||
convertedContent := githubmarkdownconvertergo.Slack(ccm.Message.Content)
|
||||
attachments := attachmentsFromMetadataOnly(ccm.Metadata)
|
||||
channelID, ts, err := api.PostMessage(t.channelID,
|
||||
slack.MsgOptionLinkNames(true),
|
||||
slack.MsgOptionEnableLinkUnfurl(),
|
||||
slack.MsgOptionText(ccm.Message.Content, true),
|
||||
slack.MsgOptionText(convertedContent, false),
|
||||
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{
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
"github.com/mudler/LocalAGI/pkg/config"
|
||||
"github.com/mudler/LocalAGI/services/connectors/common"
|
||||
"github.com/mudler/LocalAGI/pkg/xstrings"
|
||||
"github.com/mudler/LocalAGI/services/actions"
|
||||
"github.com/mudler/xlog"
|
||||
@@ -41,6 +42,7 @@ 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
|
||||
@@ -232,9 +234,6 @@ 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)
|
||||
@@ -260,9 +259,10 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID to metadata for tracking
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
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,9 +306,10 @@ func (t *Telegram) handleGroupMessage(ctx context.Context, b *bot.Bot, a *agent.
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -423,21 +424,48 @@ 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) {
|
||||
// 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()
|
||||
}
|
||||
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
|
||||
}
|
||||
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 {
|
||||
// Check if we have a placeholder message for this job
|
||||
t.placeholderMutex.RLock()
|
||||
t.placeholderMutex.Lock()
|
||||
msgID, exists := t.placeholders[state.Job.UUID]
|
||||
chatID := int64(0)
|
||||
if state.Job.Metadata != nil {
|
||||
@@ -445,18 +473,30 @@ func (t *Telegram) AgentReasoningCallback() func(state types.ActionCurrentState)
|
||||
chatID = ch
|
||||
}
|
||||
}
|
||||
t.placeholderMutex.RUnlock()
|
||||
|
||||
if !exists || msgID == 0 || chatID == 0 || t.bot == nil {
|
||||
return true // Skip if we don't have a message to update
|
||||
t.placeholderMutex.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
thought := telegramThinkingMessage + "\n\n"
|
||||
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
|
||||
}
|
||||
if state.Reasoning != "" {
|
||||
thought += "Current thought process:\n" + state.Reasoning
|
||||
acc.AppendReasoning(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,
|
||||
@@ -469,25 +509,6 @@ 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)
|
||||
@@ -702,9 +723,6 @@ 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)
|
||||
@@ -738,9 +756,10 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
t.placeholders[jobUUID] = msg.ID
|
||||
t.placeholderMutex.Unlock()
|
||||
|
||||
// Add chat ID to metadata for tracking
|
||||
// Add chat ID and conversation_id for tracking and cancel-previous-on-new-message
|
||||
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
|
||||
@@ -772,9 +791,10 @@ func (t *Telegram) handleUpdate(ctx context.Context, b *bot.Bot, a *agent.Agent,
|
||||
}
|
||||
t.activeJobsMutex.Unlock()
|
||||
|
||||
// Clean up the placeholder map
|
||||
// Clean up the placeholder map and job status
|
||||
t.placeholderMutex.Lock()
|
||||
delete(t.placeholders, jobUUID)
|
||||
delete(t.jobStatus, jobUUID)
|
||||
t.placeholderMutex.Unlock()
|
||||
}()
|
||||
|
||||
@@ -1016,6 +1036,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/types"
|
||||
|
||||
skilldomain "github.com/mudler/skillserver/pkg/domain"
|
||||
)
|
||||
|
||||
const defaultSkillsIntro = "You can use the following skills to help with the task.\nTo request the skill, you need to use the `request_skill` tool. The skill name is the name of the skill you want to use.\n"
|
||||
|
||||
// defaultSkillsTemplate is the default template that mimics the current XML behavior
|
||||
const defaultSkillsTemplate = defaultSkillsIntro + `<available_skills>
|
||||
{{range .Skills}}
|
||||
<skill>
|
||||
<name>{{escapeXML .Name}}</name>
|
||||
<description>{{escapeXML .Description}}</description>
|
||||
</skill>
|
||||
{{end}}
|
||||
</available_skills>`
|
||||
|
||||
// Skill is a local representation of a skill for template rendering
|
||||
type Skill struct {
|
||||
Name string
|
||||
Description string
|
||||
ID string
|
||||
}
|
||||
|
||||
// skillsPrompt implements agent.DynamicPrompt and injects the available skills XML block
|
||||
type skillsPrompt struct {
|
||||
listSkills func() ([]skilldomain.Skill, error)
|
||||
customTemplate string
|
||||
}
|
||||
|
||||
// NewSkillsPrompt returns a DynamicPrompt that renders the list of available skills.
|
||||
// If customTemplate is non-empty, it is used as a template with {{.Skills}} slice.
|
||||
// Otherwise, the default template is used (mimics current XML behavior).
|
||||
func NewSkillsPrompt(listSkills func() ([]skilldomain.Skill, error), customTemplate string) agent.DynamicPrompt {
|
||||
return &skillsPrompt{listSkills: listSkills, customTemplate: customTemplate}
|
||||
}
|
||||
|
||||
func (p *skillsPrompt) Render(a *agent.Agent) (types.PromptResult, error) {
|
||||
skills, err := p.listSkills()
|
||||
if err != nil {
|
||||
return types.PromptResult{}, err
|
||||
}
|
||||
|
||||
// Convert skilldomain.Skill to local Skill type for template rendering
|
||||
localSkills := make([]Skill, len(skills))
|
||||
for i, s := range skills {
|
||||
desc := ""
|
||||
if s.Metadata != nil && s.Metadata.Description != "" {
|
||||
desc = s.Metadata.Description
|
||||
}
|
||||
localSkills[i] = Skill{
|
||||
Name: s.ID,
|
||||
Description: desc,
|
||||
ID: s.ID,
|
||||
}
|
||||
}
|
||||
|
||||
// Use custom template or default
|
||||
templ := p.customTemplate
|
||||
if templ == "" {
|
||||
templ = defaultSkillsTemplate
|
||||
}
|
||||
|
||||
// Parse and execute the template
|
||||
tmpl, err := template.New("skillsPrompt").Funcs(template.FuncMap{
|
||||
"escapeXML": escapeXML,
|
||||
}).Funcs(sprig.FuncMap()).Parse(templ)
|
||||
if err != nil {
|
||||
return types.PromptResult{}, fmt.Errorf("failed to parse skills template: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.Execute(&buf, struct {
|
||||
Skills []Skill
|
||||
}{
|
||||
Skills: localSkills,
|
||||
})
|
||||
if err != nil {
|
||||
return types.PromptResult{}, fmt.Errorf("failed to execute skills template: %w", err)
|
||||
}
|
||||
|
||||
return types.PromptResult{Content: buf.String()}, nil
|
||||
}
|
||||
|
||||
func (p *skillsPrompt) Role() string {
|
||||
return "system"
|
||||
}
|
||||
|
||||
func escapeXML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
+49
-5
@@ -2,6 +2,7 @@ package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -26,22 +27,42 @@ 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
|
||||
sharedState *internalTypes.AgentSharedState
|
||||
collectionsState *CollectionsState // set when RegisterCollectionRoutes runs; used for in-process RAG
|
||||
}
|
||||
)
|
||||
|
||||
//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
|
||||
webapp := fiber.New(fiber.Config{})
|
||||
|
||||
// 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
|
||||
}))
|
||||
|
||||
a := &App{
|
||||
config: config,
|
||||
@@ -365,7 +386,20 @@ 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.Error != nil {
|
||||
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 {
|
||||
// Send error message
|
||||
xlog.Error("Error asking agent", "agent", agentName, "error", response.Error)
|
||||
errorData, err := json.Marshal(map[string]interface{}{
|
||||
@@ -555,7 +589,17 @@ func (a *App) Responses(pool *state.AgentPool, tracker *conversations.Conversati
|
||||
}
|
||||
|
||||
agentName := request.Model
|
||||
messages := append(conv, request.ToChatCompletionMessages()...)
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
agent := pool.GetAgent(agentName)
|
||||
if agent == nil {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func newVectorEngine(
|
||||
vectorEngineType string,
|
||||
llmClient *openai.Client,
|
||||
apiURL, apiKey, collectionName, dbPath, fileAssets, embeddingModel, databaseURL string,
|
||||
maxChunkSize, chunkOverlap int,
|
||||
) *rag.PersistentKB {
|
||||
switch vectorEngineType {
|
||||
case "chromem":
|
||||
xlog.Info("Chromem collection", "collectionName", collectionName, "dbPath", dbPath)
|
||||
return rag.NewPersistentChromeCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap)
|
||||
case "localai":
|
||||
xlog.Info("LocalAI collection", "collectionName", collectionName, "apiURL", apiURL)
|
||||
return 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)
|
||||
return rag.NewPersistentPostgresCollection(llmClient, collectionName, dbPath, fileAssets, embeddingModel, maxChunkSize, chunkOverlap, databaseURL)
|
||||
default:
|
||||
xlog.Error("Unknown vector engine", "engine", vectorEngineType)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// backendInProcess implements Backend using in-process state.
|
||||
type backendInProcess struct {
|
||||
state *State
|
||||
cfg *Config
|
||||
openAIClient *openai.Client
|
||||
}
|
||||
|
||||
var _ Backend = (*backendInProcess)(nil)
|
||||
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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 {
|
||||
b.state.Mu.Lock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
if exists {
|
||||
delete(b.state.Collections, collection)
|
||||
}
|
||||
b.state.Mu.Unlock()
|
||||
if !exists {
|
||||
return fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
return kb.Reset()
|
||||
}
|
||||
|
||||
func (b *backendInProcess) DeleteEntry(collection, entry string) ([]string, error) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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 {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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) {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
if !exists {
|
||||
return "", fmt.Errorf("collection not found: %s", collection)
|
||||
}
|
||||
return kb.GetEntryFilePath(entry)
|
||||
}
|
||||
|
||||
func (b *backendInProcess) EntryExists(collection, entry string) bool {
|
||||
b.state.Mu.RLock()
|
||||
kb, exists := b.state.Collections[collection]
|
||||
b.state.Mu.RUnlock()
|
||||
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)
|
||||
if collection != nil {
|
||||
st.Collections[c] = collection
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
@@ -0,0 +1,137 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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),
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAGI/core/agent"
|
||||
"github.com/mudler/LocalAGI/core/state"
|
||||
"github.com/mudler/LocalAGI/webui/collections"
|
||||
)
|
||||
|
||||
// CollectionsRAGProviderFromState delegates to the collections sub-package.
|
||||
func CollectionsRAGProviderFromState(cs *CollectionsState) func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return collections.RAGProviderFromState(cs)
|
||||
}
|
||||
|
||||
// CollectionsRAGProvider returns a provider that the pool can use when no LocalRAG URL is set.
|
||||
func (app *App) CollectionsRAGProvider() func(collectionName string) (agent.RAGDB, state.KBCompactionClient, bool) {
|
||||
return CollectionsRAGProviderFromState(app.collectionsState)
|
||||
}
|
||||
@@ -4,11 +4,13 @@ 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
|
||||
@@ -16,6 +18,17 @@ 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)
|
||||
@@ -72,12 +85,66 @@ 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)
|
||||
|
||||
@@ -0,0 +1,714 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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.
|
After Width: | Height: | Size: 933 KiB |
@@ -0,0 +1,714 @@
|
||||
: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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// 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';
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,94 @@
|
||||
<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>
|
||||
+22
-20
@@ -11,12 +11,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"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.1", "", { "dependencies": { "@eslint/object-schema": "^3.0.1", "debug": "^4.3.1", "minimatch": "^10.1.1" } }, "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA=="],
|
||||
"@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-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.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
|
||||
"@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="],
|
||||
|
||||
"@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.1", "", {}, "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg=="],
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="],
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
@@ -138,10 +138,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -212,17 +208,21 @@
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@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.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"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.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": ["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-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.0", "", { "peerDependencies": { "eslint": ">=9" } }, "sha512-ZYvmh7VfVgqR/7wR71I3Zl6hK/C5CcxdWYKZSpHawS5JCNgE4efhQWg/+/WPpgGAp9Ngp/rRZYyaIwmPQBq/lA=="],
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||
|
||||
"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-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-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="],
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"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.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
|
||||
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
@@ -416,6 +416,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.0",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.3.0",
|
||||
"react-router-dom": "^7.13.0",
|
||||
"vite": "^7.3.1"
|
||||
|
||||
+1103
-345
File diff suppressed because it is too large
Load Diff
+98
-89
@@ -1,10 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, Link } from 'react-router-dom'
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||
import { useTheme } from './contexts/ThemeContext'
|
||||
import ThemeToggle from './components/ThemeToggle'
|
||||
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') => {
|
||||
@@ -14,106 +18,111 @@ function App() {
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Toggle mobile menu
|
||||
const toggleMobileMenu = () => {
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Don't render until theme is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="app-layout">
|
||||
{/* Mobile Overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
}
|
||||
Vendored
+51
-2
@@ -11,11 +11,15 @@ 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, isConnected } = useSSE(agentName);
|
||||
const { messages: sseMessages, statusUpdates, errorMessages, streamEvents, isConnected } = useSSE(agentName);
|
||||
|
||||
// Process SSE messages into chat messages
|
||||
useEffect(() => {
|
||||
@@ -75,8 +79,14 @@ 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);
|
||||
@@ -102,6 +112,42 @@ 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;
|
||||
@@ -160,6 +206,9 @@ export function useChat(agentName) {
|
||||
sending,
|
||||
error,
|
||||
isConnected,
|
||||
streamReasoning,
|
||||
streamContent,
|
||||
streamToolCalls,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
clearError,
|
||||
|
||||
Vendored
+19
@@ -10,6 +10,7 @@ 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);
|
||||
|
||||
@@ -80,6 +81,23 @@ 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 {
|
||||
@@ -124,6 +142,7 @@ export function useSSE(agentName) {
|
||||
messages,
|
||||
statusUpdates,
|
||||
errorMessages,
|
||||
streamEvents,
|
||||
isConnected,
|
||||
reconnect,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ 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'
|
||||
@@ -20,6 +21,8 @@ document.head.appendChild(fontAwesomeLink);
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<ThemeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -14,10 +14,10 @@ function AgentSettings() {
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = `Agent Settings: ${name} - LocalAGI`;
|
||||
document.title = `${name} - Settings - LocalAGI`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'LocalAGI'; // Reset title when component unmounts
|
||||
document.title = 'LocalAGI';
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
@@ -35,14 +35,12 @@ 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -52,11 +50,10 @@ function AgentSettings() {
|
||||
// Load agent data when component mounts
|
||||
useEffect(() => {
|
||||
if (agent) {
|
||||
// Set form data from agent config
|
||||
setFormData({
|
||||
...formData,
|
||||
...agent,
|
||||
name: name // Ensure name is set correctly
|
||||
name: name
|
||||
});
|
||||
}
|
||||
}, [agent]);
|
||||
@@ -110,7 +107,7 @@ function AgentSettings() {
|
||||
return (
|
||||
<div className="settings-container">
|
||||
<div className="loading">
|
||||
<i className="fas fa-spinner fa-spin"></i>
|
||||
<div className="loader" />
|
||||
<p>Loading agent settings...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +118,7 @@ function AgentSettings() {
|
||||
return (
|
||||
<div className="settings-container">
|
||||
<div className="error">
|
||||
<i className="fas fa-exclamation-triangle"></i>
|
||||
<i className="fas fa-exclamation-triangle" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,44 +127,47 @@ function AgentSettings() {
|
||||
|
||||
return (
|
||||
<div className="settings-container">
|
||||
{/* Page Header */}
|
||||
<header className="page-header">
|
||||
<h1>
|
||||
<i className="fas fa-cog"></i> Agent Settings - {name}
|
||||
</h1>
|
||||
<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>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className={`action-btn ${agent?.active ? 'warning' : 'success'}`}
|
||||
onClick={handleToggleStatus}
|
||||
>
|
||||
{agent?.active ? (
|
||||
<><i className="fas fa-pause"></i> Pause Agent</>
|
||||
) : (
|
||||
<><i className="fas fa-play"></i> Start Agent</>
|
||||
)}
|
||||
<i className={`fas ${agent?.active ? 'fa-pause' : 'fa-play'}`} />
|
||||
{agent?.active ? 'Pause Agent' : 'Start Agent'}
|
||||
</button>
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<i className="fas fa-trash"></i> Delete Agent
|
||||
<i className="fas fa-trash" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="settings-content">
|
||||
{/* 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>
|
||||
<AgentForm
|
||||
isEdit={true}
|
||||
formData={formData}
|
||||
setFormData={setFormData}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
submitButtonText="Save Changes"
|
||||
metadata={metadata}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
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 || {};
|
||||
// ChatCompletionRequest summary
|
||||
const completion = observable?.completion || {};
|
||||
|
||||
// Chat message 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 {
|
||||
@@ -16,98 +22,90 @@ 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';
|
||||
}
|
||||
|
||||
// FunctionDefinition summary
|
||||
|
||||
// Function definition summary
|
||||
let creationFunctionDef = '';
|
||||
if (creation?.function_definition?.name) {
|
||||
creationFunctionDef = `Function: ${creation.function_definition.name}`;
|
||||
}
|
||||
// FunctionParams summary
|
||||
|
||||
// Function params summary
|
||||
let creationFunctionParams = '';
|
||||
if (creation?.function_params && Object.keys(creation.function_params).length > 0) {
|
||||
creationFunctionParams = `Params: ${JSON.stringify(creation.function_params)}`;
|
||||
}
|
||||
|
||||
// --- COMPLETION SUMMARIES ---
|
||||
const completion = observable?.completion || {};
|
||||
// ChatCompletionResponse summary
|
||||
|
||||
// Completion 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 => {
|
||||
return { message: m }
|
||||
}) }
|
||||
chatCompletion = {
|
||||
choices: completion.conversation.map(m => ({ 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) {
|
||||
toolCallSummary = toolCalls.map(tc => {
|
||||
const 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 arguments]';
|
||||
args = '[Unserializable]';
|
||||
}
|
||||
}
|
||||
const toolName = tc.function?.name || tc.name || 'unknown';
|
||||
return `Tool call: ${toolName}(${args})`;
|
||||
}).join('\n');
|
||||
return `${toolName}(${args})`;
|
||||
}).join(', ');
|
||||
completionChatMsg = { toolCallSummary, message: lastChoice?.message?.content || '' };
|
||||
} else {
|
||||
completionChatMsg = lastChoice?.message?.content || '';
|
||||
}
|
||||
completionChatMsg = lastChoice?.message?.content || '';
|
||||
// Attach toolCallSummary to completionChatMsg for rendering
|
||||
if (toolCallSummary) {
|
||||
completionChatMsg = { toolCallSummary, message: completionChatMsg };
|
||||
}
|
||||
// Else, it's just a string
|
||||
}
|
||||
|
||||
// ActionResult summary
|
||||
// Action result summary
|
||||
let completionActionResult = '';
|
||||
if (completion?.action_result) {
|
||||
completionActionResult = `Action Result: ${String(completion.action_result).slice(0, 100)}`;
|
||||
completionActionResult = String(completion.action_result).slice(0, 100);
|
||||
}
|
||||
// AgentState summary
|
||||
|
||||
// Agent state summary
|
||||
let completionAgentState = '';
|
||||
if (completion?.agent_state) {
|
||||
completionAgentState = `Agent State: ${JSON.stringify(completion.agent_state)}`;
|
||||
completionAgentState = JSON.stringify(completion.agent_state);
|
||||
}
|
||||
|
||||
// Error summary
|
||||
let completionError = '';
|
||||
if (completion?.error) {
|
||||
completionError = `Error: ${completion.error}`;
|
||||
completionError = 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 any triggers';
|
||||
completionFilter = 'Failed to match triggers';
|
||||
} else if (completion.filter_result?.triggered_by) {
|
||||
completionFilter = `Triggered by ${completion.filter_result.triggered_by}`;
|
||||
}
|
||||
|
||||
if (completion?.filter_result?.failed_by)
|
||||
completionFilter = `${completionFilter ? completionFilter + ', ' : ''}Failed by ${completion.filter_result.failed_by}`;
|
||||
if (completion?.filter_result?.failed_by) {
|
||||
completionFilter += `${completionFilter ? ', ' : ''}Failed by ${completion.filter_result.failed_by}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Only show if any summary is present
|
||||
|
||||
// Check if any summary exists
|
||||
if (!creationChatMsg && !creationFunctionDef && !creationFunctionParams &&
|
||||
!completionChatMsg && !completionActionResult &&
|
||||
!completionAgentState && !completionError && !completionFilter) {
|
||||
@@ -115,120 +113,170 @@ function ObservableSummary({ observable }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, margin: '2px 0 0 0' }}>
|
||||
{/* CREATION */}
|
||||
<div className="observable-summary">
|
||||
{creationChatMsg && (
|
||||
<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 className="observable-summary-item creation" title={creationChatMsg}>
|
||||
<i className="fas fa-comment-dots" />
|
||||
<span>{creationChatMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
{creationFunctionDef && (
|
||||
<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 className="observable-summary-item creation" title={creationFunctionDef}>
|
||||
<i className="fas fa-code" />
|
||||
<span>{creationFunctionDef}</span>
|
||||
</div>
|
||||
)}
|
||||
{creationFunctionParams && (
|
||||
<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 className="observable-summary-item creation" title={creationFunctionParams}>
|
||||
<i className="fas fa-sliders-h" />
|
||||
<span>{creationFunctionParams}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* COMPLETION */}
|
||||
{/* COMPLETION: Tool call summary if present */}
|
||||
{completionChatMsg && typeof completionChatMsg === 'object' && completionChatMsg.toolCallSummary && (
|
||||
<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 className="observable-summary-item tool-call" title={completionChatMsg.toolCallSummary}>
|
||||
<i className="fas fa-tools" />
|
||||
<span>{completionChatMsg.toolCallSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{completionActionResult && (
|
||||
<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 className="observable-summary-item tool-call" title={completionActionResult}>
|
||||
<i className="fas fa-bolt" />
|
||||
<span>{completionActionResult}</span>
|
||||
</div>
|
||||
)}
|
||||
{completionAgentState && (
|
||||
<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 className="observable-summary-item completion" title={completionAgentState}>
|
||||
<i className="fas fa-brain" />
|
||||
<span>{completionAgentState}</span>
|
||||
</div>
|
||||
)}
|
||||
{completionError && (
|
||||
<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 className="observable-summary-item error" title={completionError}>
|
||||
<i className="fas fa-exclamation-triangle" />
|
||||
<span>{completionError}</span>
|
||||
</div>
|
||||
)}
|
||||
{completionFilter && (
|
||||
<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 className="observable-summary-item completion" title={completionFilter}>
|
||||
<i className="fas fa-shield-alt" />
|
||||
<span>{completionFilter}</span>
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStatus() {
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
const { name } = useParams();
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
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 = `Agent Status: ${name} - LocalAGI`;
|
||||
document.title = `${name} - Status - LocalAGI`;
|
||||
}
|
||||
return () => {
|
||||
document.title = 'LocalAGI'; // Reset title when component unmounts
|
||||
document.title = 'LocalAGI';
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
// Fetch initial status data
|
||||
// Fetch initial status data and setup SSE
|
||||
useEffect(() => {
|
||||
const fetchStatusData = async () => {
|
||||
try {
|
||||
@@ -248,7 +296,6 @@ function AgentStatus() {
|
||||
|
||||
fetchStatusData();
|
||||
|
||||
// Helper to build observable tree from map
|
||||
function buildObservableTree(map) {
|
||||
const nodes = Object.values(map);
|
||||
const nodeMap = {};
|
||||
@@ -264,7 +311,6 @@ function AgentStatus() {
|
||||
return roots;
|
||||
}
|
||||
|
||||
// Fetch initial observable history
|
||||
const fetchObservables = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/agent/${name}/observables`);
|
||||
@@ -272,9 +318,7 @@ 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));
|
||||
}
|
||||
@@ -284,49 +328,35 @@ function AgentStatus() {
|
||||
};
|
||||
fetchObservables();
|
||||
|
||||
// Setup SSE connection for live updates
|
||||
// Setup SSE connection
|
||||
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,
|
||||
};
|
||||
// Events can be received out of order
|
||||
if (data.creation)
|
||||
updated.creation = data.creation;
|
||||
if (data.completion)
|
||||
updated.completion = data.completion;
|
||||
const updated = { ...data, ...prev };
|
||||
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] };
|
||||
});
|
||||
});
|
||||
@@ -335,11 +365,8 @@ function AgentStatus() {
|
||||
console.error('SSE connection error:', err);
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (sse) {
|
||||
sse.close();
|
||||
}
|
||||
sse.close();
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
@@ -351,10 +378,8 @@ 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);
|
||||
@@ -363,201 +388,124 @@ 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>
|
||||
<div></div>
|
||||
<p>Loading agent status...</p>
|
||||
<div className="agent-status-container">
|
||||
<div className="loading-container">
|
||||
<div className="loader" />
|
||||
<p>Loading agent status...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
<Link to="/agents">
|
||||
<i className="fas fa-arrow-left"></i> Back to Agents
|
||||
</Link>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{loading && <div>Loading...</div>}
|
||||
{statusData && (
|
||||
<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 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>
|
||||
{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 && (
|
||||
<p className="agent-subtitle">Monitor agent activity and observables in real-time</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
{/* Current Status Section */}
|
||||
{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>
|
||||
<p className="status-section-description">
|
||||
Real-time summary of the agent'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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,15 @@ function Chat() {
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Use our custom chat hook
|
||||
const {
|
||||
messages,
|
||||
sending,
|
||||
error,
|
||||
isConnected,
|
||||
sendMessage,
|
||||
const {
|
||||
messages,
|
||||
sending,
|
||||
error,
|
||||
isConnected,
|
||||
streamReasoning,
|
||||
streamContent,
|
||||
streamToolCalls,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
clearError
|
||||
} = useChat(name);
|
||||
@@ -33,7 +36,7 @@ function Chat() {
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
}, [messages, streamContent, streamReasoning, streamToolCalls]);
|
||||
|
||||
// Show error toast if there's an error
|
||||
useEffect(() => {
|
||||
@@ -116,6 +119,46 @@ 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>
|
||||
|
||||
|
||||
@@ -56,12 +56,6 @@ 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">
|
||||
|
||||
@@ -1,42 +1,84 @@
|
||||
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'; // Reset title when component unmounts
|
||||
document.title = 'LocalAGI';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
setFile(selectedFile);
|
||||
}
|
||||
// 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 handleImport = async () => {
|
||||
if (!file) {
|
||||
showToast('Please select a file to import', 'error');
|
||||
const handleFileChange = (e) => {
|
||||
handleFileSelected(e.target.files[0]);
|
||||
};
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
await agentApi.importAgent(formData);
|
||||
showToast('Agent imported successfully', 'success');
|
||||
navigate('/agents');
|
||||
await agentApi.createAgent(formData);
|
||||
showToast(`Agent "${formData.name}" imported successfully`, 'success');
|
||||
navigate(`/settings/${formData.name}`);
|
||||
} catch (err) {
|
||||
showToast(`Error importing agent: ${err.message}`, 'error');
|
||||
} finally {
|
||||
@@ -44,6 +86,41 @@ 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">
|
||||
@@ -54,17 +131,10 @@ function ImportAgent() {
|
||||
|
||||
<div className="import-agent-content">
|
||||
<div className="section-box">
|
||||
<div className="file-dropzone" onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) {
|
||||
setFile(droppedFile);
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="file-dropzone" onDrop={handleDrop} onDragOver={(e) => e.preventDefault()}>
|
||||
<div className="dropzone-content">
|
||||
<i className="fas fa-cloud-upload-alt"></i>
|
||||
<h2>Drop your agent file here</h2>
|
||||
<h2>Drop your agent JSON file here</h2>
|
||||
<p>or</p>
|
||||
<label htmlFor="fileInput" className="action-btn">
|
||||
<i className="fas fa-folder-open"></i> Select File
|
||||
@@ -72,35 +142,12 @@ function ImportAgent() {
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
accept=".json,.yaml,.yml"
|
||||
accept=".json"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
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;
|
||||
@@ -0,0 +1,461 @@
|
||||
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, 1–1024 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;
|
||||
@@ -0,0 +1,310 @@
|
||||
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 "Enable Skills" 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;
|
||||
@@ -9,6 +9,9 @@ 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';
|
||||
@@ -54,6 +57,22 @@ 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 />
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+103
-44
@@ -1,53 +1,40 @@
|
||||
/* LocalAGI Theme - CSS Variables System */
|
||||
/* Inspired by LocalAI's elegant professional design */
|
||||
/* Supports both Dark and Light themes */
|
||||
|
||||
/* ===================================
|
||||
BASE VARIABLES (Shared)
|
||||
=================================== */
|
||||
:root {
|
||||
/* 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 */
|
||||
/* Brand Colors - Primary Palette (Consistent across themes) */
|
||||
--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; /* Teal - secondary actions */
|
||||
--color-secondary: #14B8A6;
|
||||
--color-secondary-hover: #0D9488;
|
||||
--color-secondary-light: rgba(20, 184, 166, 0.1);
|
||||
|
||||
/* Accent Colors */
|
||||
--color-accent: #8B5CF6; /* Purple - special states */
|
||||
--color-accent: #8B5CF6;
|
||||
--color-accent-hover: #7C3AED;
|
||||
--color-accent-light: rgba(139, 92, 246, 0.1);
|
||||
--color-accent-purple: #A78BFA; /* Light purple for gradients */
|
||||
--color-accent-teal: #2DD4BF; /* Light teal for gradients */
|
||||
--color-accent-purple: #A78BFA;
|
||||
--color-accent-teal: #2DD4BF;
|
||||
|
||||
/* 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 */
|
||||
/* Status Colors (Consistent across themes) */
|
||||
--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);
|
||||
|
||||
@@ -60,6 +47,7 @@
|
||||
--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 */
|
||||
@@ -70,9 +58,9 @@
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Spacing Scale */
|
||||
@@ -82,24 +70,95 @@
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Legacy Variable Mappings (for backward compatibility) */
|
||||
|
||||
/* Layout */
|
||||
--sidebar-width: 240px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--header-height: 64px;
|
||||
|
||||
/* Legacy Variable Mappings */
|
||||
--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);
|
||||
--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;
|
||||
}
|
||||
|
||||
/* ===================================
|
||||
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);
|
||||
}
|
||||
|
||||
Vendored
+229
@@ -24,6 +24,16 @@ 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) {
|
||||
@@ -293,3 +303,222 @@ 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);
|
||||
},
|
||||
};
|
||||
|
||||
Vendored
+24
@@ -48,5 +48,29 @@ 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`,
|
||||
}
|
||||
};
|
||||
|
||||
+42
-83
@@ -17,6 +17,7 @@ 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"
|
||||
)
|
||||
@@ -34,18 +35,10 @@ 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",
|
||||
@@ -61,29 +54,6 @@ 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"))
|
||||
@@ -95,21 +65,7 @@ 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))
|
||||
@@ -118,46 +74,14 @@ 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))
|
||||
@@ -268,6 +192,39 @@ 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")
|
||||
@@ -317,7 +274,9 @@ func getApiKeyErrorHandler(opaqueErrors bool, apiKeys []string) fiber.ErrorHandl
|
||||
if opaqueErrors {
|
||||
return ctx.SendStatus(401)
|
||||
}
|
||||
return ctx.Status(401).Render("old/views/login", fiber.Map{})
|
||||
return ctx.Status(401).Render("public/views/login", fiber.Map{
|
||||
"Title": "Login Required",
|
||||
})
|
||||
}
|
||||
if opaqueErrors {
|
||||
return ctx.SendStatus(500)
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
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"})
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,16 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user