Compare commits

...

64 Commits

Author SHA1 Message Date
dependabot[bot] d9f728fe83 chore(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 23:23:33 +00:00
Richard Palethorpe c65d62e762 chore: Update fiber to v2.52.11 (#461)
Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-03-31 19:38:55 +02:00
Ettore Di Giacinto 736ccbb95e forward reasoning
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 17:28:45 +00:00
Ettore Di Giacinto 640d97b6db small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 15:38:18 +00:00
Ettore Di Giacinto 23344560f0 small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 14:25:29 +00:00
Ettore Di Giacinto 3d0af0088e split run from start
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 13:48:23 +00:00
Ettore Di Giacinto 1b87514b2f allow to ask directly
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-25 08:33:17 +00:00
Ettore Di Giacinto b485b77037 Adapt client too
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-21 00:47:23 +00:00
Ettore Di Giacinto 07caa0b95d Adapt client too
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-21 00:46:41 +00:00
Ettore Di Giacinto 43c65ec7e8 feat: update localrecall to support files with same names in the collections
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-19 17:45:13 +00:00
dependabot[bot] cc2d2838ca chore(deps-dev): bump eslint-plugin-react-refresh from 0.5.0 to 0.5.2 in /webui/react-ui (#430)
chore(deps-dev): bump eslint-plugin-react-refresh in /webui/react-ui

Bumps [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) from 0.5.0 to 0.5.2.
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.0...v0.5.2)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 08:43:54 +01:00
Ettore Di Giacinto da286065e1 feat: support streaming mode for tool calls
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-15 22:34:07 +00:00
Ettore Di Giacinto 9438d39f70 feat: support streaming mode for tool calls
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-15 22:33:51 +00:00
Ettore Di Giacinto e38f13ab8c feat: add handler to serve raw files
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-14 22:28:28 +00:00
LocalAI [bot] 2d2da7df95 feat: add --prompt flag for foreground agent mode (#454)
* feat: add --prompt flag for foreground agent mode

- Add --prompt/-p flag to 'agent run' command
- When --prompt is provided, runs agent in foreground mode
- Creates agent, executes Ask() with the prompt, prints response, and exits
- Supports both agent name and --config file input modes
- Follows existing code patterns in the repository

* fix: correct JobResult field access for error and response

---------

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
2026-03-11 17:53:47 +01:00
LocalAI [bot] dc21ee83bc feat: implement 'agent run' CLI command (#448) (#449)
* feat: implement 'agent run' CLI command (#448)

- Add 'local-agi agent run' command supporting agent name or config file
- Support 'local-agi agent run <name>' to run agent from registry (pool.json)
- Support 'local-agi agent run --config <file.json>' to run from JSON config
- Extract web server into 'local-agi serve' subcommand
- Implement agent name lookup from registry
- Add JSON config file parsing and validation
- Create standalone agent execution logic
- Add proper error handling for invalid inputs
- Reuse existing service factories (actions, connectors, filters, skills)
- Environment variable fallback for config values
- No web server - agent runs in foreground with clean SIGINT/SIGTERM handling

References: https://github.com/mudler/LocalAGI/issues/448

* refactor(cmd): use pool to start agent instead of duplicating logic

Replace ~220 lines of duplicated agent initialization code in
startStandaloneAgent() with a call to pool.StartAgentStandalone().
The new pool method delegates to the existing startAgentWithConfig(),
eliminating code duplication between the CLI and the pool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add default help command to root

* fix: default to serve command when no subcommand provided

The e2e tests fail because when the container starts with no arguments,
the root command shows help instead of starting the web server.

Changed the root command to call serveCmd.RunE() directly when no
subcommand is provided, ensuring the web server starts by default.

This fixes the CI e2e test failure where the web server wasn't starting.

* fix: add serve subcommand to Dockerfile ENTRYPOINT

* refactor: remove unused GetRAGProvider function

* refactor: centralize env var setup in cmd/env.go

---------

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:09:02 +01:00
dependabot[bot] 9b744d2bac chore(deps-dev): bump eslint from 10.0.0 to 10.0.3 in /webui/react-ui (#451)
Bumps [eslint](https://github.com/eslint/eslint) from 10.0.0 to 10.0.3.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.0.0...v10.0.3)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-09 23:05:19 +01:00
LocalAI [bot] 4177479f82 chore(deps): bump cogito from v0.9.2 to v0.9.3 (#452)
Co-authored-by: LocalAI Bot <localai-bot@example.com>
2026-03-09 22:37:10 +01:00
LocalAI [bot] 34db83dc32 fix(slack): correct thread timestamp and improve file upload handling (#447)
Root cause: uploadJobResultFiles was passing msgTs (placeholder reply timestamp)
as thread_ts for file uploads. Slack's API rejects this - it requires the
parent thread timestamp, not a reply's timestamp.

Changes:
1. Use 'ts' (thread root timestamp) instead of 'msgTs' in replyToUpdateMessage
2. Fix type handling in attachmentsFromMetadataOnly to handle []interface{}
3. Download and upload generated images (e.g., DALL-E) as files instead of
   just adding link attachments, to preserve temporary URLs
4. Remove dead code: generateAttachmentsFromJobResponse was never called

This matches Telegram connector behavior where files are properly uploaded
rather than just referenced by URL.

Co-authored-by: localai-bot <localai-bot@noreply.github.com>
2026-03-08 15:59:37 +01:00
Ettore Di Giacinto 5a27c471ca chore: refactoring to make it importable
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-06 16:49:48 +01:00
Ettore Di Giacinto 3ecbbf3eac chore: minor enhancement to be fully importable
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-06 16:39:30 +01:00
Ettore Di Giacinto d9bf193457 feat(ui): allow to edit agent before importing (#444)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-03-04 18:39:11 +01:00
Ettore Di Giacinto 2acdc6688c fix(conv): merge system messages
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-27 11:08:16 +01:00
LocalAI [bot] fb20d12ae1 feat: add automatic compaction settings to agent config (#437)
* Merge origin/main and resolve conflicts: keep all features including auto-compaction and evaluation

* fix: resolve merge conflict syntax errors in config.go

* feat: wire auto-compaction options to cogito

Wire enableAutoCompaction and autoCompactionThreshold options to
cogito.WithCompactionThreshold() when starting the agent.

---------

Co-authored-by: Team Coding Agent 1 <team-coding-agent-1@local>
2026-02-26 21:32:27 +01:00
Ettore Di Giacinto 7bbec7b6dd fix(templates): add agentname in prompt templates (#438)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-26 15:43:13 +01:00
LocalAI [bot] 905ffcb185 chore: update cogito go dependency to latest main (#436)
Updated github.com/mudler/cogito from
v0.9.2-0.20260223101954-070948df04f7 to
v0.9.2-0.20260225234859-b76691637703

Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
2026-02-26 01:12:12 +01:00
LocalAI [bot] cb82322df2 feat: add inner monologue template support for scheduler and template-based skills prompt (#435)
* feat: add scheduler task template and template-based skills prompt

- Add WithSchedulerTaskTemplate option for recurring tasks run by scheduler
- Expose scheduler_task_template in core/state/config.go for user configuration
- Add WithSkillPromptTemplate option for custom skill prompt templates
- Use {{.Skills}} slice in templates to iterate over Skill.Name, Skill.Description
- Default template mimics current XML behavior with <available_skills> format
- Rename customIntro to customTemplate in services/skills for consistency

* cleanups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Update core/state/config.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 18:44:49 +01:00
LocalAI [bot] 56ecd1921e chore: disable reasoning by default (#433)
Set EnableReasoning DefaultValue to false in agent config form metadata.

Signed-off-by: mudler <mudler@localai.io>
2026-02-24 21:50:55 +01:00
Ettore Di Giacinto a0faa14ffe fix(slack/files): trying to debug files upload
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 22:39:23 +00:00
Ettore Di Giacinto 7494a1559e chore(observables): add missing observables
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 21:09:01 +00:00
Ettore Di Giacinto 3dad5acf1f chore(pdf): refactor and try to always send pdf
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 20:53:02 +00:00
Ettore Di Giacinto 99f7e3ab60 fix: finish the job once
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 19:10:29 +01:00
Ettore Di Giacinto 5b90d4c3e2 chore: stop tool call when model calls send_message
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 19:06:31 +01:00
Ettore Di Giacinto 9406d699cc chore: allow to configure inner monologue template
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 18:43:25 +01:00
Ettore Di Giacinto 78defbad32 fix: avoid panics
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 15:31:04 +01:00
Ettore Di Giacinto 2efadbf6c4 bump go mod
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 11:55:42 +01:00
Ettore Di Giacinto b46c300307 chore: enable more behavioral settings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-23 11:42:30 +01:00
Ettore Di Giacinto d2407105a4 fix: remove the duplicated unlock call
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 23:24:30 +00:00
Ettore Di Giacinto 32a1a59389 feat: add accumulator to print tool status/results during execution (#427)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 22:44:19 +01:00
Ettore Di Giacinto 61a89aaf58 feat(pdf): improve pdf markdown rendering (#426)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 22:42:36 +01:00
Ettore Di Giacinto c5e3df41ee fix: atomic writes to the pool, create backups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 19:11:46 +01:00
Ettore Di Giacinto cb37f80724 fix(slack): process all metadata
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 10:12:13 +01:00
Ettore Di Giacinto bc256fbbe2 chore: avoid panics from Slack connector
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 09:49:03 +01:00
Ettore Di Giacinto 8014d22588 fix: unify api keys for collection endpoint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 23:39:19 +00:00
Ettore Di Giacinto 410ba7a467 feat: allow to manage external localrecall instances (#425)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-22 00:02:12 +01:00
Ettore Di Giacinto c56cc43552 feat(memory): add knowledgebase and memory management from LocalRecall (#424)
* feat: integrate knowledge base management

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* refactorings

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 23:17:33 +01:00
Ettore Di Giacinto bc567ef7dd feat(skills): add skills management (#423)
* feat(skills): add skills management

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* improve ui

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update Dockerfile.webui

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update go.mod

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update webui/skills_handlers.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Address feedback from review

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* allow to customize skill prompt

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Fixups

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-21 22:21:33 +01:00
Ettore Di Giacinto f29e98bccb fix(reasoning): do not update if no new content
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-21 15:15:57 +01:00
Ettore Di Giacinto 73d8304e2e fix: do not imply there is an action
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 22:44:45 +00:00
Ettore Di Giacinto 52e8df6599 fix(slack): another attempt to fix markdown result display (#422)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 23:15:02 +01:00
Ettore Di Giacinto 3828dd7ccc chore(deps): bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 22:07:33 +00:00
Ettore Di Giacinto cea2d9d618 chore: hook reasoning callback to status updates
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 21:51:00 +00:00
Ettore Di Giacinto 5daf4bda8e fix: do not let agents override each other configuration
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 10:08:16 +01:00
Ettore Di Giacinto 508a307e8b chore: update cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-20 10:02:21 +01:00
Ettore Di Giacinto 8a16ad5407 feat: bump cogito, use LocalAILLM
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-19 21:25:05 +00:00
Ettore Di Giacinto 27cc58a25f feat(ui): left navbar, dark/light theme (#421)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 23:21:34 +01:00
Ettore Di Giacinto a8decbcc85 chore: fix login
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 17:31:12 +01:00
Ettore Di Giacinto 1c083abc60 Add back old webui (login)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 16:18:36 +01:00
Ettore Di Giacinto 8f64d246d8 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 16:05:16 +01:00
Ettore Di Giacinto 700047c6b9 chore: allow to set force reasoning tool
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-17 13:13:41 +01:00
dependabot[bot] 419a4363c3 chore(deps-dev): bump @types/react from 19.2.13 to 19.2.14 in /webui/react-ui (#420)
chore(deps-dev): bump @types/react in /webui/react-ui

Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 19.2.13 to 19.2.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 22:35:14 +01:00
Ettore Di Giacinto 292d0c9c19 chore: improvements to sink state handling
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 19:29:03 +01:00
Ettore Di Giacinto 1147e02844 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 17:31:49 +01:00
Ettore Di Giacinto c8e83dc4b9 chore: bump cogito
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-16 15:45:19 +01:00
84 changed files with 11516 additions and 1712 deletions
+2 -2
View File
@@ -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: |
+2 -2
View File
@@ -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
View File
@@ -16,8 +16,8 @@ COPY webui/react-ui/ ./
# Build the React UI
RUN bun run build
# Use a temporary build image based on Golang 1.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"]
+32 -14
View File
@@ -18,7 +18,7 @@ Try on [![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-b
Create customizable AI assistants, automations, chat bots and agents that run 100% locally. No need for agentic Python libraries or cloud service keys, just bring your GPU (or even just CPU) and a web browser.
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and 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 |
+15
View File
@@ -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)
}
+355
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+101
View File
@@ -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))
})
})
+7 -2
View File
@@ -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
View File
@@ -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
}
}
+23 -2
View File
@@ -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),
+9
View File
@@ -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)
}
+13
View File
@@ -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) {
+33 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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]
}
+5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+5 -15
View File
@@ -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
+5 -15
View File
@@ -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
View File
@@ -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:
+44 -13
View File
@@ -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
+127 -40
View File
@@ -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=
+2 -100
View File
@@ -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
View File
@@ -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
}
+13 -4
View File
@@ -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,
+412
View File
@@ -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, "")
}
}
}
+48
View File
@@ -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\", 23"
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": content,
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
info, err := os.Stat(paths[0])
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 0))
})
It("generates PDF with markdown table", func() {
content := "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": content,
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
info, err := os.Stat(paths[0])
Expect(err).ToNot(HaveOccurred())
Expect(info.Size()).To(BeNumerically(">", 0))
})
})
+101
View File
@@ -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()
}
+3 -24
View File
@@ -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
View File
@@ -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{
+65 -44
View File
@@ -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",
+106
View File
@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}
+182
View File
@@ -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
View File
@@ -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 {
+270
View File
@@ -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
}
+185
View File
@@ -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
}
}
+18
View File
@@ -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
}
+55
View File
@@ -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
}
+12
View File
@@ -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
+137
View File
@@ -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
}
+22
View File
@@ -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)
}
+362
View File
@@ -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),
}))
}
}
+17
View File
@@ -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)
}
+67
View File
@@ -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)
+714
View File
@@ -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;
}
}
+44
View File
@@ -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

+714
View File
@@ -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;
}
}
+44
View File
@@ -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';
});
});
});
+354
View File
@@ -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>
+94
View File
@@ -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
View File
@@ -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=="],
+3 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+98 -89
View File
@@ -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>
)
}
+99
View File
@@ -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;
}
+51 -2
View File
@@ -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,
+19
View File
@@ -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,
};
+4 -1
View File
@@ -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>,
)
+30 -30
View File
@@ -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>
);
+270 -322
View File
@@ -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&apos;s thoughts and actions
</p>
{showStatus && (
<div style={{ marginTop: '1rem' }}>
{(Array.isArray(statusData?.History) && statusData.History.length === 0) && (
<div style={{ color: 'var(--color-text-muted)', textAlign: 'center', padding: '2rem' }}>
<i className="fas fa-inbox" style={{ fontSize: '2rem', marginBottom: '0.5rem', display: 'block' }} />
No status history available
</div>
)}
{Array.isArray(statusData?.History) && statusData.History.map((item, idx) => (
<div key={idx} className="card" style={{ marginBottom: '0.75rem' }}>
{typeof item === 'string'
? item.replace(/<br\s*\/?>/gi, '\n')
: JSON.stringify(item, null, 2)}
</div>
))}
</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>
);
}
+50 -7
View File
@@ -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>
-6
View File
@@ -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">
+97 -50
View File
@@ -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>
+614
View File
@@ -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;
+461
View File
@@ -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, 11024 chars) <span style={{ color: 'var(--color-error)' }}>*</span></label>
<textarea
id="skill-desc"
name="description"
className="input"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
maxLength={1024}
rows={2}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-license">License (optional)</label>
<input
id="skill-license"
name="license"
type="text"
className="input"
value={form.license}
onChange={(e) => setForm((f) => ({ ...f, license: e.target.value }))}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-compat">Compatibility (optional, max 500 chars)</label>
<input
id="skill-compat"
name="compatibility"
type="text"
className="input"
value={form.compatibility}
onChange={(e) => setForm((f) => ({ ...f, compatibility: e.target.value }))}
maxLength={500}
/>
</div>
<div className="mb-4">
<label htmlFor="skill-allowed-tools">Allowed tools (optional)</label>
<input
id="skill-allowed-tools"
name="allowedTools"
type="text"
className="input"
value={form.allowedTools}
onChange={(e) => setForm((f) => ({ ...f, allowedTools: e.target.value }))}
placeholder="tool1, tool2"
/>
</div>
</div>
<div style={{ display: activeSection === 'content-section' ? 'block' : 'none' }}>
<h3 className="section-title">Content</h3>
<div className="mb-4">
<label htmlFor="skill-content">Skill content (markdown)</label>
<textarea
id="skill-content"
name="content"
className="input"
value={form.content}
onChange={(e) => setForm((f) => ({ ...f, content: e.target.value }))}
rows={14}
style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}
/>
</div>
</div>
{activeSection === 'resources-section' && (
<div style={{ display: 'block' }}>
{isNew || !name ? (
<div className="section-box" style={{ padding: '1.5rem', marginTop: 0 }}>
<h3 className="section-title">Resources</h3>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 0 }}>
Save the skill first to add scripts, references, and assets. After creating the skill, use this tab to upload files and manage resources.
</p>
</div>
) : (
<ResourcesSection skillName={name} showToast={showToast} />
)}
</div>
)}
<div className="form-actions" style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end', marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid var(--color-border)' }}>
<Link to="/skills" className="action-btn">
<i className="fas fa-times" /> Cancel
</Link>
<button type="submit" className="action-btn success" disabled={saving}>
<i className="fas fa-save" /> {saving ? 'Saving...' : (isNew ? 'Create skill' : 'Save changes')}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
);
}
export default SkillEdit;
+310
View File
@@ -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 &quot;Enable Skills&quot; per agent to give them access.</p>
</div>
<div className="header-actions" style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
className="input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ width: '220px' }}
/>
<Link to="/skills/new" className="action-btn success">
<i className="fas fa-plus" /> New skill
</Link>
<label className="action-btn" style={{ margin: 0, cursor: 'pointer' }}>
<input type="file" accept=".tar.gz" onChange={handleImport} disabled={importing} style={{ display: 'none' }} />
{importing ? 'Importing...' : <><i className="fas fa-file-import" /> Import</>}
</label>
<button type="button" className="action-btn" onClick={() => setShowGitRepos((v) => !v)}>
<i className="fas fa-code-branch" /> Git Repos
</button>
</div>
</header>
{showGitRepos && (
<div className="section-box" style={{ marginBottom: '1.5rem' }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
<i className="fas fa-code-branch" /> Git repositories
</h2>
<p className="page-description" style={{ marginBottom: '1rem' }}>
Add Git repositories to pull skills from. Skills will appear in the list after sync.
</p>
<form onSubmit={addGitRepo} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<input
type="url"
className="input"
placeholder="https://github.com/user/repo or git@github.com:user/repo.git"
value={gitRepoUrl}
onChange={(e) => setGitRepoUrl(e.target.value)}
style={{ flex: '1', minWidth: '200px' }}
/>
<button type="submit" className="action-btn success" disabled={gitReposAction === 'add'}>
{gitReposAction === 'add' ? 'Adding...' : 'Add repo'}
</button>
</form>
{gitReposLoading ? (
<p>Loading repos...</p>
) : gitRepos.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>No Git repos configured. Add one above.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{gitRepos.map((r) => (
<li key={r.id} className="card" style={{ padding: '0.75rem 1rem', marginBottom: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>{r.name || r.url}</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.9rem', marginLeft: '0.5rem' }}>{r.url}</span>
{!r.enabled && <span className="badge" style={{ marginLeft: '0.5rem' }}>Disabled</span>}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button type="button" className="action-btn" onClick={() => syncGitRepo(r.id)} disabled={gitReposAction === r.id}>
{gitReposAction === r.id ? 'Syncing...' : <><i className="fas fa-sync-alt" /> Sync</>}
</button>
<button type="button" className="action-btn" onClick={() => toggleGitRepo(r.id)} title={r.enabled ? 'Disable' : 'Enable'}>
<i className={`fas fa-toggle-${r.enabled ? 'on' : 'off'}`} />
</button>
<button type="button" className="action-btn delete-btn" onClick={() => deleteGitRepo(r.id)} title="Remove repo">
<i className="fas fa-trash" />
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
{loading ? (
<p>Loading skills...</p>
) : skills.length === 0 ? (
<div className="card">
<p>No skills found. Create a skill or import one.</p>
<Link to="/skills/new" className="action-btn success" style={{ marginTop: '0.5rem' }}>Create skill</Link>
</div>
) : (
<div className="skills-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{skills.map((s) => (
<div key={s.name} className="card" style={{ padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>{s.name}</h3>
{s.readOnly && <span className="badge" style={{ fontSize: '0.75rem' }}>Read-only</span>}
</div>
<p style={{ margin: '0 0 0.75rem 0', color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
{s.description || 'No description'}
</p>
<div className="agent-table-actions" style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{!s.readOnly && (
<Link to={`/skills/edit/${encodeURIComponent(s.name)}`} className="action-btn" title="Edit skill">
<i className="fas fa-edit" /> Edit
</Link>
)}
{!s.readOnly && (
<button type="button" className="action-btn delete-btn" onClick={() => deleteSkill(s.name)} title="Delete skill">
<i className="fas fa-trash" /> Delete
</button>
)}
<button type="button" className="action-btn" onClick={() => exportSkill(s.name)} title="Export as .tar.gz">
<i className="fas fa-download" /> Export
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default Skills;
+19
View File
@@ -9,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
View File
@@ -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);
}
+229
View File
@@ -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);
},
};
+24
View File
@@ -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
View File
@@ -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)
+861
View File
@@ -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"})
}
+396
View File
@@ -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>
+16
View File
@@ -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)
}