Compare commits

...

53 Commits

Author SHA1 Message Date
Ettore Di Giacinto 76d82b1f4c drop unused code, run compaction immediately at start
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 23:07:47 +01:00
Ettore Di Giacinto 0de2228ba6 chore: allow to choose how we store things in the vector database
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 22:22:51 +01:00
Ettore Di Giacinto 0762d6fabb bump cogito (#416)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 21:25:43 +01:00
Ettore Di Giacinto 6dc515bae5 feat: expose further cogito settings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-14 19:15:55 +01:00
Ettore Di Giacinto 9110e9cfdb fix: set sink state tool with a custom name
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-10 21:50:15 +00:00
Ettore Di Giacinto 9965e3df36 Comment out DisableSinkState in agent configuration
Comment out the DisableSinkState option in agent.go.
2026-02-10 22:43:26 +01:00
Ettore Di Giacinto da9e8d7c20 feat: add support to medatata to messages sent from the agents (#415)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-10 18:18:53 +01:00
dependabot[bot] 7f2e52c0c8 chore(deps-dev): bump @vitejs/plugin-react from 5.1.2 to 5.1.3 in /webui/react-ui (#402)
chore(deps-dev): bump @vitejs/plugin-react in /webui/react-ui

Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 5.1.2 to 5.1.3.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.3/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.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-02-10 16:45:09 +01:00
dependabot[bot] 6a33484fce chore(deps-dev): bump @eslint/js from 9.39.2 to 10.0.1 in /webui/react-ui (#414)
chore(deps-dev): bump @eslint/js in /webui/react-ui

Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.2 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/HEAD/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 15:32:36 +01:00
dependabot[bot] bdf12e4c3d chore(deps-dev): bump @types/react from 19.2.10 to 19.2.13 in /webui/react-ui (#413)
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.10 to 19.2.13.
- [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.13
  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-10 13:03:02 +01:00
dependabot[bot] 5743d6f757 chore(deps-dev): bump eslint from 9.39.2 to 10.0.0 in /webui/react-ui (#412)
Bumps [eslint](https://github.com/eslint/eslint) from 9.39.2 to 10.0.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v10.0.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 13:02:50 +01:00
dependabot[bot] 0845e9dc81 chore(deps-dev): bump eslint-plugin-react-refresh from 0.4.26 to 0.5.0 in /webui/react-ui (#401)
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.4.26 to 0.5.0.
- [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.4.26...v0.5.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 09:07:40 +01:00
Ettore Di Giacinto ef2a6e2296 chore: bump LocalRecall
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 21:26:57 +00:00
Ettore Di Giacinto 272c54f851 fix: int type passing from react
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 20:49:37 +00:00
Ettore Di Giacinto 775b1a9ab8 chore: drop agents avatars (#411)
This functionality makes LocalAGI harder to starts without no real gain.
While having agents avatars is nice, on generating a new agent is
required a new image to be created, which, on small HW could become a
huge computational strain.

It moves the agent list view to tabular, and cleanups the index page to
improve readability.

We can go back at this, but the motivations are mainly:

- Do not waste computation and be more lightweight
- Less models to download unless the user wants to download image models
- Let's not make look the application "weird" as images could be
  generated which are not good looking at all

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 18:59:52 +01:00
Ettore Di Giacinto 673909918a feat: improve theme to follow LocalAI (#410)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 15:46:01 +01:00
Ettore Di Giacinto 807b7e756b Small fixups
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-09 11:22:43 +01:00
Ettore Di Giacinto c75da2235d Rename actions from knowledge base to memory 2026-02-09 00:41:27 +01:00
Ettore Di Giacinto 80a12f6662 Rename reminder actions to task actions 2026-02-09 00:33:40 +01:00
Ettore Di Giacinto 1128615140 Rename ConversationActionName to 'send_message' 2026-02-09 00:32:28 +01:00
Ettore Di Giacinto 8847bfe0fc chore: allow to set scheduler poll interval
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 23:26:27 +01:00
Ettore Di Giacinto 25ce5475de chore: drop deprecated set reminders
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 23:03:03 +01:00
Ettore Di Giacinto 2a4bc0eb0a chore: mark scheduled jobs so we inject tools to initiate new conversations
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 22:56:25 +01:00
Ettore Di Giacinto d0bfa01494 Change runner to arc-runner-localagent 2026-02-08 22:34:31 +01:00
Copilot 57023d6386 Integrate task scheduler into agent reminder system (#408)
* Initial plan

* Add task scheduler with cron/interval/once support and comprehensive tests

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add comprehensive documentation for scheduler package

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add working example demonstrating scheduler usage

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Update .gitignore to exclude example binaries and task files

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Integrate task scheduler into existing reminder system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Fix scheduler integration and compilation errors

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Remove standalone scheduler example - integrated into agent system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Update scheduler README to reflect integration with agent system

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* replace old reminders logic

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

* A task scheduler for each agent

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

* fixups

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

* tests

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

* fixups

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

* change to duration

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-08 22:19:22 +01:00
Copilot 1c3e9b36e7 Add generate_pdf action with connector support (#409)
* Initial plan

* Add PDF generation action with tests and connector support

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Address code review feedback: Add error logging and clarify filename description

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

* Add path traversal protection for filename parameter

Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mudler <2420543+mudler@users.noreply.github.com>
2026-02-08 21:09:04 +01:00
Ettore Di Giacinto 4a3ee02081 chore: cleanup
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-07 09:00:12 +01:00
Ettore Di Giacinto d804ef66e5 fix: register actions
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 23:21:38 +01:00
Ettore Di Giacinto cea3b7b111 chore: drop LocalOperator actions, we will use MCP for this (#407)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 21:51:54 +01:00
Ettore Di Giacinto 1d574069f2 feat: allow to adjust knowledgebase usage (#406)
This allows to configure specifically how the Knowledge base should be
accessible to the agent.

- Auto search: When enabled, every message will trigger a search to the
  knowledge base. This is useful mostly if you mean to give immediate
  context to the agent. Particularly suited for search agents.
- As tools: it will inject search and add tools to the knowledge base so
  the agent can handle these autonomously.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 21:36:44 +01:00
dependabot[bot] b09da13111 chore(deps-dev): bump globals from 17.1.0 to 17.3.0 in /webui/react-ui (#403)
Bumps [globals](https://github.com/sindresorhus/globals) from 17.1.0 to 17.3.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.1.0...v17.3.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 21:05:16 +01:00
Ettore Di Giacinto ba2eafe4b2 fix: pass-by state result
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-06 16:50:33 +01:00
Ettore Di Giacinto cbe4cd62ca fix: correct API url
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 22:49:18 +01:00
Ettore Di Giacinto 4f4ad5069d feat(gen-song): add song generation action (#404)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 19:00:50 +01:00
Ettore Di Giacinto 0950791be6 fix: skip KB if not enabled
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-05 10:46:35 +01:00
Ettore Di Giacinto f47d5d0e01 fix(ui): checkbox event propagation
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-03 12:25:57 +01:00
Ettore Di Giacinto e3d4ac3d0b Enhance LocalAGI description in README
Expanded description of LocalAGI to include agent creation and skillserver integration.
2026-02-01 18:30:48 +01:00
Ettore Di Giacinto c98178d41d chore(settings): move mcp config to the MCP section and rework form (#400)
* chore: move MCP configuration to the MCP section

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

* add MCP stdio form, visually split the two

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-01 18:23:41 +01:00
Ettore Di Giacinto 4e89c8aac0 chore: attach observables for standalone jobs (#399)
* chore: add observable for standalone jobs

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

* chore: update docker compose with latest models

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

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-02-01 09:36:24 +01:00
Ettore Di Giacinto c3bd2bee42 feat: bump cogito, allow to enable guidelines
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:41:14 +01:00
Ettore Di Giacinto 9f358abb54 chore(memory): open index only once
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:25:17 +01:00
Ettore Di Giacinto 456c32c284 use volumes
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-31 09:19:54 +01:00
Ettore Di Giacinto b527cbc332 Revert "chore: do not wait for agents to start, add timeout on MCP connections"
This reverts commit 7cc0c3e85b.
2026-01-31 09:19:26 +01:00
Ettore Di Giacinto 7cc0c3e85b chore: do not wait for agents to start, add timeout on MCP connections
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 20:20:46 +00:00
Ettore Di Giacinto 73ce451c13 feat: add automatic compaction of knowledge base (#398)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 18:00:06 +01:00
vugenti 72a7807a81 HTTP MCP Fixes (#395)
Add support for Streamable MCP over HTTP - fallback on legacy SSE transport. Fix: Prevent HTTP MCP servers from being closed immediately after initialization.
2026-01-30 15:43:34 +01:00
Ettore Di Giacinto 16775f356a Use bleve for indexing short-term memory (#397)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-30 15:41:23 +01:00
dependabot[bot] a363682b39 chore(deps-dev): bump vite from 7.3.0 to 7.3.1 in /webui/react-ui (#387)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.1
  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-01-29 10:33:37 +01:00
Ettore Di Giacinto 2288e7f08d feat: update localrecall and use postgresql as default (#394)
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-01-29 10:26:25 +01:00
dependabot[bot] c703495e8c chore(deps-dev): bump react-router-dom from 7.11.0 to 7.13.0 in /webui/react-ui (#392)
chore(deps-dev): bump react-router-dom in /webui/react-ui

Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.11.0 to 7.13.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.13.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 09:40:41 +01:00
dependabot[bot] 73c1158aa2 chore(deps): bump react and @types/react in /webui/react-ui (#393)
Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) and [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react). These dependencies needed to be updated together.

Updates `react` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `@types/react` from 19.2.7 to 19.2.9
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.9
  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-01-29 09:40:30 +01:00
dependabot[bot] a1036a166b chore(deps): bump react-dom from 19.2.3 to 19.2.4 in /webui/react-ui (#391)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.3 to 19.2.4.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  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-01-28 11:40:05 +01:00
dependabot[bot] 9a669e2552 chore(deps-dev): bump globals from 16.5.0 to 17.1.0 in /webui/react-ui (#390)
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to 17.1.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v16.5.0...v17.1.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 11:39:52 +01:00
70 changed files with 5369 additions and 2826 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ concurrency:
cancel-in-progress: true
jobs:
containerImages:
runs-on: ubuntu-latest
#runs-on: ubuntu-latest
runs-on: arc-runner-localagent
steps:
- name: Checkout
uses: actions/checkout@v6
+2
View File
@@ -8,3 +8,5 @@ LocalAGI
**/.env
.vscode
volumes/
example/scheduler/scheduler
example/scheduler/example_tasks.json
+1 -1
View File
@@ -15,7 +15,7 @@ run-nokb:
$(MAKE) run KBDISABLEINDEX=true
webui/react-ui/dist:
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
docker run --entrypoint /bin/bash -v $(ROOT_DIR):/app:z oven/bun:1 -c "cd /app/webui/react-ui && bun install && bun run build"
.PHONY: build
build: webui/react-ui/dist
+1 -1
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. A complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
**LocalAGI** is a powerful, self-hostable AI Agent platform that allows you to design AI automations without writing code. Create Agents with a couple of clicks, connect via MCP and give it skills with [skillserver](https://github.com/mudler/skillserver). Every agent exposes a complete drop-in replacement for OpenAI's Responses APIs with advanced agentic capabilities. No clouds. No data leaks. Just pure local AI that works on consumer-grade hardware (CPU and GPU).
## 🛡️ Take Back Your Privacy
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/sashabaranov/go-openai/jsonschema"
)
const ConversationActionName = "new_conversation"
const ConversationActionName = "send_message"
func NewConversation() *ConversationAction {
return &ConversationAction{}
+127 -52
View File
@@ -6,19 +6,24 @@ import (
"strings"
"time"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
"github.com/robfig/cron/v3"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
ReminderActionName = "set_reminder"
ListRemindersName = "list_reminders"
RemoveReminderName = "remove_reminder"
RecurringReminderActionName = "set_recurring_task"
OneTimeReminderActionName = "set_onetime_task"
ListRemindersName = "list_tasks"
RemoveReminderName = "remove_task"
)
func NewReminder() *ReminderAction {
return &ReminderAction{}
func NewRecurringReminder() *RecurringReminderAction {
return &RecurringReminderAction{}
}
func NewOneTimeReminder() *OneTimeReminderAction {
return &OneTimeReminderAction{}
}
func NewListReminders() *ListRemindersAction {
@@ -29,7 +34,8 @@ func NewRemoveReminder() *RemoveReminderAction {
return &RemoveReminderAction{}
}
type ReminderAction struct{}
type RecurringReminderAction struct{}
type OneTimeReminderAction struct{}
type ListRemindersAction struct{}
type RemoveReminderAction struct{}
@@ -37,46 +43,87 @@ type RemoveReminderParams struct {
Index int `json:"index"`
}
func (a *ReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.ReminderActionResponse{}
func (a *RecurringReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.RecurringReminderParams{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
// Validate the cron expression
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
_, err = parser.Parse(result.CronExpr)
task, err := scheduler.NewTask(
sharedState.AgentName,
result.Message,
scheduler.ScheduleTypeCron,
result.CronExpr,
)
if err != nil {
return types.ActionResult{}, err
}
// Calculate next run time
now := time.Now()
schedule, _ := parser.Parse(result.CronExpr) // We can ignore the error since we validated above
nextRun := schedule.Next(now)
task.Metadata["reminder_type"] = "user_created"
// Set the reminder details
result.LastRun = now
result.NextRun = nextRun
// IsRecurring is set by the user through the action parameters
// Store the reminder in the shared state
if sharedState.Reminders == nil {
sharedState.Reminders = make([]types.ReminderActionResponse, 0)
err = sharedState.Scheduler.CreateTask(task)
if err != nil {
return types.ActionResult{}, err
}
sharedState.Reminders = append(sharedState.Reminders, result)
return types.ActionResult{
Result: "Reminder set successfully",
Result: fmt.Sprintf("Recurring reminder set successfully (ID: %s). Next run: %s", task.ID, task.NextRun.Format(time.RFC3339)),
Metadata: map[string]interface{}{
"reminder": result,
"task_id": task.ID,
"message": result.Message,
"next_run": task.NextRun,
},
}, nil
}
func (a *OneTimeReminderAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := types.OneTimeReminderParams{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, err
}
// Validate the delay parses correctly before creating the task
_, err = scheduler.ParseDuration(result.Delay)
if err != nil {
return types.ActionResult{}, fmt.Errorf("invalid delay format, expected a duration like '30m', '2h', '1d', '1d12h': %w", err)
}
task, err := scheduler.NewTask(
sharedState.AgentName,
result.Message,
scheduler.ScheduleTypeOnce,
result.Delay,
)
if err != nil {
return types.ActionResult{}, err
}
task.Metadata["reminder_type"] = "user_created"
err = sharedState.Scheduler.CreateTask(task)
if err != nil {
return types.ActionResult{}, err
}
return types.ActionResult{
Result: fmt.Sprintf("One-time reminder set in %s (at %s, ID: %s)", result.Delay, task.NextRun.Format(time.RFC3339), task.ID),
Metadata: map[string]interface{}{
"task_id": task.ID,
"message": result.Message,
"next_run": task.NextRun,
},
}, nil
}
func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
tasks, err := sharedState.Scheduler.GetAllTasks()
if err != nil {
return types.ActionResult{}, err
}
if len(tasks) == 0 {
return types.ActionResult{
Result: "No reminders set",
}, nil
@@ -84,22 +131,25 @@ func (a *ListRemindersAction) Run(ctx context.Context, sharedState *types.AgentS
var result strings.Builder
result.WriteString("Current reminders:\n")
for i, reminder := range sharedState.Reminders {
for i, task := range tasks {
status := "one-time"
if reminder.IsRecurring {
if task.ScheduleType == scheduler.ScheduleTypeCron || task.ScheduleType == scheduler.ScheduleTypeInterval {
status = "recurring"
}
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s)\n",
result.WriteString(fmt.Sprintf("%d. %s (Next run: %s, Status: %s, ID: %s)\n",
i+1,
reminder.Message,
reminder.NextRun.Format(time.RFC3339),
status))
task.Prompt,
task.NextRun.Format(time.RFC3339),
status,
task.ID))
}
return types.ActionResult{
Result: result.String(),
Metadata: map[string]interface{}{
"reminders": sharedState.Reminders,
"tasks": tasks,
},
}, nil
}
@@ -111,7 +161,12 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
return types.ActionResult{}, err
}
if sharedState.Reminders == nil || len(sharedState.Reminders) == 0 {
tasks, err := sharedState.Scheduler.GetAllTasks()
if err != nil {
return types.ActionResult{}, err
}
if len(tasks) == 0 {
return types.ActionResult{
Result: "No reminders to remove",
}, nil
@@ -119,23 +174,29 @@ func (a *RemoveReminderAction) Run(ctx context.Context, sharedState *types.Agent
// Convert from 1-based index to 0-based
index := removeParams.Index - 1
if index < 0 || index >= len(sharedState.Reminders) {
if index < 0 || index >= len(tasks) {
return types.ActionResult{}, fmt.Errorf("invalid reminder index: %d", removeParams.Index)
}
// Remove the reminder
removed := sharedState.Reminders[index]
sharedState.Reminders = append(sharedState.Reminders[:index], sharedState.Reminders[index+1:]...)
task := tasks[index]
err = sharedState.Scheduler.DeleteTask(task.ID)
if err != nil {
return types.ActionResult{}, err
}
return types.ActionResult{
Result: fmt.Sprintf("Removed reminder: %s", removed.Message),
Result: fmt.Sprintf("Removed reminder: %s", task.Prompt),
Metadata: map[string]interface{}{
"removed_reminder": removed,
"removed_task_id": task.ID,
},
}, nil
}
func (a *ReminderAction) Plannable() bool {
func (a *RecurringReminderAction) Plannable() bool {
return true
}
func (a *OneTimeReminderAction) Plannable() bool {
return true
}
@@ -147,10 +208,10 @@ func (a *RemoveReminderAction) Plannable() bool {
return true
}
func (a *ReminderAction) Definition() types.ActionDefinition {
func (a *RecurringReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: ReminderActionName,
Description: "Set a reminder for the agent to wake up and perform a task based on a cron schedule. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
Name: RecurringReminderActionName,
Description: "Set a recurring reminder for the agent to wake up and perform a task on a cron schedule. The reminder will keep repeating. Examples: '0 0 * * *' (daily at midnight), '0 */2 * * *' (every 2 hours), '0 0 * * 1' (every Monday at midnight)",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
@@ -158,14 +219,28 @@ func (a *ReminderAction) Definition() types.ActionDefinition {
},
"cron_expr": {
Type: jsonschema.String,
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'second minute hour day month weekday'",
},
"is_recurring": {
Type: jsonschema.Boolean,
Description: "Whether this reminder should repeat according to the cron schedule (true) or trigger only once (false)",
Description: "Cron expression for scheduling (e.g. '0 0 * * *' for daily at midnight). Format: 'minute hour day month weekday'",
},
},
Required: []string{"message", "cron_expr", "is_recurring"},
Required: []string{"message", "cron_expr"},
}
}
func (a *OneTimeReminderAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: OneTimeReminderActionName,
Description: "Set a one-time reminder for the agent to wake up and perform a task after a delay. The reminder triggers only once and is then automatically removed. Use this when asked to do something 'in X minutes/hours/days'. Examples: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
Properties: map[string]jsonschema.Definition{
"message": {
Type: jsonschema.String,
Description: "The message or task to be reminded about",
},
"delay": {
Type: jsonschema.String,
Description: "How long to wait before triggering. Use Go duration format: '30m' (30 minutes), '2h' (2 hours), '1d' (1 day), '1d12h' (1.5 days), '2h30m' (2.5 hours)",
},
},
Required: []string{"message", "delay"},
}
}
+3 -4
View File
@@ -94,7 +94,7 @@ func (m Messages) GetLatestUserMessage() *openai.ChatCompletionMessage {
// getAvailableActionsForJob returns available actions including user-defined ones for a specific job
func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
// Start with regular available actions
baseActions := a.availableActions()
baseActions := a.availableActions(job)
// Add user-defined actions from the job
userTools := job.GetUserTools()
@@ -107,12 +107,11 @@ func (a *Agent) getAvailableActionsForJob(job *types.Job) types.Actions {
return baseActions
}
func (a *Agent) availableActions() types.Actions {
func (a *Agent) availableActions(j *types.Job) types.Actions {
// defaultActions := append(a.options.userActions, action.NewReply())
defaultActions := slices.Clone(a.options.userActions)
if a.options.initiateConversations && a.selfEvaluationInProgress { // && self-evaluation..
if j.Metadata["type"] == "scheduled" || (a.options.initiateConversations && a.selfEvaluationInProgress) { // && self-evaluation..
acts := append(defaultActions, action.NewConversation())
if a.options.enableHUD {
acts = append(acts, action.NewState())
+153 -109
View File
@@ -18,9 +18,9 @@ import (
"github.com/mudler/xlog"
"github.com/mudler/LocalAGI/core/action"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/robfig/cron/v3"
"github.com/sashabaranov/go-openai"
)
@@ -30,6 +30,20 @@ const (
SystemRole = "system"
)
// NoToolToCallArgs defines the arguments for the no_tool_to_call sink state tool
type NoToolToCallArgs struct {
Reasoning string `json:"reasoning" description:"The reasoning for why no tool is being called"`
}
// NoToolToCallTool is a custom sink state tool that logs when no other tool is needed
type NoToolToCallTool struct{}
// Run executes the no_tool_to_call tool and logs a message
func (t NoToolToCallTool) Run(args NoToolToCallArgs) (string, any, error) {
xlog.Info("No tool to call - agent decided no action was needed", "reasoning", args.Reasoning)
return fmt.Sprintf("No action needed: %s", args.Reasoning), nil, nil
}
type Agent struct {
sync.Mutex
options *options
@@ -43,19 +57,22 @@ type Agent struct {
selfEvaluationInProgress bool
pause bool
newConversations chan openai.ChatCompletionMessage
newConversations chan *types.ConversationMessage
mcpSessions []*mcp.ClientSession
// only contains the MCP action definitions for observables
mcpActionDefinitions types.Actions
subscriberMutex sync.Mutex
newMessagesSubscribers []func(openai.ChatCompletionMessage)
newMessagesSubscribers []func(*types.ConversationMessage)
observer Observer
llm cogito.LLM
sharedState *types.AgentSharedState
// Task scheduler for managing reminders
taskScheduler *scheduler.Scheduler
}
type RAGDB interface {
@@ -87,7 +104,7 @@ func New(opts ...Option) (*Agent, error) {
currentState: &types.AgentInternalState{},
llm: llmClient,
context: types.NewActionContext(ctx, cancel),
newConversations: make(chan openai.ChatCompletionMessage),
newConversations: make(chan *types.ConversationMessage),
newMessagesSubscribers: options.newConversationsSubscribers,
sharedState: types.NewAgentSharedState(options.lastMessageDuration),
}
@@ -118,6 +135,28 @@ func New(opts ...Option) (*Agent, error) {
a.initMCPActions()
xlog.Info("Done populating actions from MCP Servers")
// Initialize task scheduler for reminders
schedulerPath := options.schedulerStorePath
if schedulerPath == "" {
schedulerPath = "scheduled_tasks.json"
}
store, err := scheduler.NewJSONStore(schedulerPath)
if err != nil {
return nil, fmt.Errorf("failed to create scheduler store: %v", err)
}
executor := &agentSchedulerExecutor{agent: a}
pollInterval := options.schedulerPollInterval
if pollInterval == 0 {
pollInterval = 30 * time.Second
}
a.taskScheduler = scheduler.NewScheduler(store, executor, pollInterval)
a.sharedState.Scheduler = a.taskScheduler
a.sharedState.AgentName = a.Character.Name
xlog.Info("Task scheduler initialized", "store_path", schedulerPath, "poll_interval", pollInterval)
xlog.Info(
"Agent created",
"agent", a.Character.Name,
@@ -142,19 +181,21 @@ func (a *Agent) startNewConversationsConsumer() {
return
case msg := <-a.newConversations:
xlog.Debug("New conversation", "agent", a.Character.Name, "message", msg.Content)
xlog.Debug("New conversation", "agent", a.Character.Name, "message", msg.Message.Content)
a.subscriberMutex.Lock()
subs := a.newMessagesSubscribers
a.subscriberMutex.Unlock()
for _, s := range subs {
s(msg)
if s != nil && msg != nil {
s(msg)
}
}
}
}
}()
}
func (a *Agent) AddSubscriber(f func(openai.ChatCompletionMessage)) {
func (a *Agent) AddSubscriber(f func(*types.ConversationMessage)) {
a.subscriberMutex.Lock()
defer a.subscriberMutex.Unlock()
a.newMessagesSubscribers = append(a.newMessagesSubscribers, f)
@@ -218,7 +259,11 @@ func (a *Agent) Execute(j *types.Job) *types.JobResult {
}
a.Enqueue(j)
return j.Result.WaitResult()
result, err := j.Result.WaitResult(a.context.Context)
if err != nil {
return nil
}
return result
}
func (a *Agent) Enqueue(j *types.Job) {
@@ -267,10 +312,16 @@ func (a *Agent) TTS(ctx context.Context, text string) ([]byte, error) {
var ErrContextCanceled = fmt.Errorf("context canceled")
func (a *Agent) Stop() {
xlog.Debug("Stopping agent", "agent", a.Character.Name)
// Stop the scheduler
a.taskScheduler.Stop()
xlog.Info("Task scheduler stopped")
a.Lock()
defer a.Unlock()
xlog.Debug("Stopping agent", "agent", a.Character.Name)
a.closeMCPSTDIOServers()
a.closeMCPServers()
a.context.Cancel()
}
@@ -637,7 +688,7 @@ func (a *Agent) validateBuiltinTools(job *types.Job) {
}
// Get available actions
availableActions := a.availableActions()
availableActions := a.availableActions(job)
for _, tool := range builtinTools {
functionName := tool.Name
@@ -796,7 +847,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
if a.options.enableHUD {
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(), "")
prompt, err := renderTemplate(hudTemplate, a.prepareHUD(), a.availableActions(job), "")
if err != nil {
job.Result.Finish(fmt.Errorf("error renderTemplate: %w", err))
return
@@ -859,6 +910,14 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
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]
@@ -871,6 +930,29 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
a.observer.Update(*obs)
}
// Use full ActionResult (including Metadata) from action result,
// so connectors receive e.g. songs_paths, images_url for sending files.
actionResult := &types.ActionResult{
Result: t.Result,
}
if t.ResultData != nil {
switch res := t.ResultData.(type) {
case types.ActionResult:
actionResult = &res
}
}
// Merge action metadata into job metadata so it accumulates across actions
// and is available when ConversationAction runs
if actionResult.Metadata != nil {
if job.Metadata == nil {
job.Metadata = make(map[string]interface{})
}
for key, value := range actionResult.Metadata {
job.Metadata[key] = value
}
}
aa := allActions.Find(t.Name)
state := types.ActionState{
ActionCurrentState: types.ActionCurrentState{
@@ -879,14 +961,14 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
Params: types.ActionParams(t.ToolArguments.Arguments),
Reasoning: t.ToolArguments.Reasoning,
},
ActionResult: types.ActionResult{Result: t.Result},
ActionResult: *actionResult,
}
job.Result.SetResult(state)
job.CallbackWithResult(state)
conv = a.addFunctionResultToConversation(job.GetContext(), aa, types.ActionParams(t.ToolArguments.Arguments), types.ActionResult{Result: t.Result}, conv)
conv = a.addFunctionResultToConversation(job.GetContext(), aa, types.ActionParams(t.ToolArguments.Arguments), *actionResult, conv)
}),
cogito.WithToolCallBack(
func(tc *cogito.ToolChoice) bool {
func(tc *cogito.ToolChoice, _ *cogito.SessionState) cogito.ToolCallDecision {
xlog.Debug("Tool call back", "tool_call", tc)
@@ -899,7 +981,9 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
xlog.Debug("User-defined action chosen, returning tool call", "action", chosenAction.Definition().Name)
a.replyWithToolCall(job, conv, tc.Arguments, chosenAction, tc.Reasoning)
userTool = true
return false
return cogito.ToolCallDecision{
Approved: false,
}
}
if a.observer != nil && job.Obs != nil {
@@ -922,14 +1006,18 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
switch tc.Name {
case action.StopActionName:
return false
return cogito.ToolCallDecision{
Approved: false,
}
case action.ConversationActionName:
message := action.ConversationActionResponse{}
toolArgs, _ := json.Marshal(tc.Arguments)
if err := json.Unmarshal([]byte(toolArgs), &message); err != nil {
xlog.Error("Error unmarshalling conversation response", "error", err)
job.Result.Finish(fmt.Errorf("error unmarshalling conversation response: %w", err))
return false
return cogito.ToolCallDecision{
Approved: false,
}
}
msg := openai.ChatCompletionMessage{
@@ -937,9 +1025,15 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
Content: message.Message,
}
// Get accumulated metadata from job (e.g., images, files generated by previous actions in this job)
// This is per-job metadata, so parallel jobs won't interfere with each other
metadata := job.Metadata
go func(agent *Agent) {
xlog.Info("Sending new conversation to channel", "agent", agent.Character.Name, "message", msg.Content)
agent.newConversations <- msg
xlog.Info("Sending new conversation to channel", "agent", agent.Character.Name, "message", msg.Content, "metadata_keys", len(metadata))
// Send ConversationMessage with both the message and accumulated metadata
agent.newConversations <- types.NewConversationMessage(msg).WithMetadata(metadata)
// Job metadata is automatically cleared when job finishes, no need to manually clear
}(a)
job.Result.Conversation = []openai.ChatCompletionMessage{
@@ -947,7 +1041,9 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
job.Result.SetResponse("decided to initiate a new conversation")
job.Result.Finish(nil)
return true
return cogito.ToolCallDecision{
Approved: true,
}
case action.StateActionName:
// We need to store the result in the state
state := types.AgentInternalState{}
@@ -961,7 +1057,9 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
}
a.observer.Update(*obs)
}
return false
return cogito.ToolCallDecision{
Approved: false,
}
}
// update the current state with the one we just got from the action
a.currentState = &state
@@ -982,7 +1080,9 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
a.observer.Update(*obs)
}
return false
return cogito.ToolCallDecision{
Approved: false,
}
}
}
@@ -1010,7 +1110,9 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
job.Result.Finish(nil)
}
return cont
return cogito.ToolCallDecision{
Approved: cont,
}
},
),
}
@@ -1020,12 +1122,24 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
if a.options.enableEvaluation {
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)
cogitoOpts = append(cogitoOpts, cogito.WithReviewerLLM(llmClient))
}
}
if a.options.disableSinkState {
cogitoOpts = append(cogitoOpts, cogito.DisableSinkState)
}
if a.options.forceReasoning {
cogitoOpts = append(cogitoOpts, cogito.WithForceReasoning())
}
if a.options.enableGuidedTools {
cogitoOpts = append(cogitoOpts, cogito.EnableGuidedTools)
}
if a.options.maxEvaluationLoops > 0 {
cogitoOpts = append(cogitoOpts,
cogito.WithMaxAttempts(a.options.maxEvaluationLoops),
@@ -1069,13 +1183,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
return
}
responseFragment, err := a.llm.Ask(job.GetContext(), fragment)
if err != nil {
job.Result.Finish(err)
return
}
result := a.cleanupLLMResponse(responseFragment.LastMessage().Content)
result := a.cleanupLLMResponse(fragment.LastMessage().Content)
conv = append(fragment.Messages, openai.ChatCompletionMessage{
Role: "assistant",
@@ -1117,84 +1225,6 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
xlog.Debug("Agent is running periodically", "agent", a.Character.Name)
// Check for reminders that need to be triggered
now := time.Now()
var triggeredReminders []types.ReminderActionResponse
var remainingReminders []types.ReminderActionResponse
for _, reminder := range a.sharedState.Reminders {
xlog.Debug("Checking reminder", "reminder", reminder)
if now.After(reminder.NextRun) {
triggeredReminders = append(triggeredReminders, reminder)
xlog.Debug("Reminder triggered", "reminder", reminder)
// Calculate next run time for recurring reminders
if reminder.IsRecurring {
xlog.Debug("Reminder is recurring", "reminder", reminder)
parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(reminder.CronExpr)
if err == nil {
nextRun := schedule.Next(now)
xlog.Debug("Next run time", "reminder", reminder, "nextRun", nextRun)
reminder.LastRun = now
reminder.NextRun = nextRun
remainingReminders = append(remainingReminders, reminder)
}
}
} else {
xlog.Debug("Reminder not triggered", "reminder", reminder)
remainingReminders = append(remainingReminders, reminder)
}
}
// Update the reminders list
a.sharedState.Reminders = remainingReminders
// Handle triggered reminders
for _, reminder := range triggeredReminders {
xlog.Info("Processing triggered reminder", "agent", a.Character.Name, "message", reminder.Message)
// Create a more natural conversation flow for the reminder
reminderJob := types.NewJob(
types.WithText(fmt.Sprintf("I have a reminder for you: %s", reminder.Message)),
types.WithReasoningCallback(a.options.reasoningCallback),
types.WithResultCallback(a.options.resultCallback),
)
// Add the reminder message to the job's metadata
reminderJob.Metadata = map[string]interface{}{
"message": reminder.Message,
"is_reminder": true,
}
// Process the reminder as a normal conversation
a.consumeJob(reminderJob, UserRole)
// After the reminder job is complete, ensure the user is notified
if reminderJob.Result != nil && reminderJob.Result.Conversation != nil {
// Get the last assistant message from the conversation
var lastAssistantMsg *openai.ChatCompletionMessage
for i := len(reminderJob.Result.Conversation) - 1; i >= 0; i-- {
if reminderJob.Result.Conversation[i].Role == AssistantRole {
lastAssistantMsg = &reminderJob.Result.Conversation[i]
break
}
}
if lastAssistantMsg != nil && lastAssistantMsg.Content != "" {
// Send the reminder response to the user
msg := openai.ChatCompletionMessage{
Role: "assistant",
Content: fmt.Sprintf("Reminder Update: %s\n\n%s", reminder.Message, lastAssistantMsg.Content),
}
go func(agent *Agent) {
xlog.Info("Sending reminder response to user", "agent", agent.Character.Name, "message", msg.Content)
agent.newConversations <- msg
}(a)
}
}
}
if !a.options.standaloneJob {
return
}
@@ -1210,12 +1240,26 @@ func (a *Agent) periodicallyRun(timer *time.Timer) {
types.WithReasoningCallback(a.options.reasoningCallback),
types.WithResultCallback(a.options.resultCallback),
)
// Attach observable so UI can show standalone job progress (decisions, actions, reasoning)
if a.observer != nil {
obs := a.observer.NewObservable()
obs.Name = "standalone"
obs.Icon = "clock"
a.observer.Update(*obs)
whatNext.Obs = obs
}
a.consumeJob(whatNext, SystemRole)
xlog.Info("STOP -- Periodically run is done", "agent", a.Character.Name)
}
func (a *Agent) Run() error {
// Start the scheduler
a.taskScheduler.Start()
xlog.Info("Task scheduler started")
a.startNewConversationsConsumer()
xlog.Debug("Agent is now running", "agent", a.Character.Name)
// The agent run does two things:
+2 -2
View File
@@ -296,9 +296,9 @@ var _ = Describe("Agent test", func() {
WithModel(testModel),
WithLLMAPIKey(apiKeyURL),
WithTimeout("10m"),
WithNewConversationSubscriber(func(m openai.ChatCompletionMessage) {
WithNewConversationSubscriber(func(m *types.ConversationMessage) {
mu.Lock()
message = m
message = m.Message
mu.Unlock()
}),
WithActions(
+162 -6
View File
@@ -1,6 +1,7 @@
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
@@ -10,14 +11,24 @@ import (
"github.com/mudler/cogito"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
)
func (a *Agent) knowledgeBaseLookup(job *types.Job, conv Messages) Messages {
if (!a.options.enableKB && !a.options.enableLongTermMemory && !a.options.enableSummaryMemory) ||
len(conv) <= 0 {
// Only run KB recall/lookup when KB is explicitly enabled; long-term/summary memory
// only affect saving in saveConversation, not this lookup.
if !a.options.enableKB || len(conv) <= 0 {
xlog.Debug("[Knowledge Base Lookup] Disabled, skipping", "agent", a.Character.Name)
return conv
}
if !a.options.kbAutoSearch {
xlog.Debug("[Knowledge Base Lookup] Auto search disabled, skipping", "agent", a.Character.Name)
return conv
}
if a.options.ragdb == nil {
xlog.Debug("[Knowledge Base Lookup] No RAG DB configured, skipping", "agent", a.Character.Name)
return conv
}
var obs *types.Observable
if job != nil && job.Obs != nil && a.observer != nil {
@@ -137,12 +148,157 @@ func (a *Agent) saveCurrentConversation(conv Messages) {
xlog.Error("Error storing into memory", "error", err)
}
} else {
for _, message := range conv {
if message.Role == "user" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing into memory", "error", err)
// Use the conversation storage mode to determine what to store
switch a.options.conversationStorageMode {
case StoreWholeConversation:
// Store the entire conversation as a single block
if len(conv) > 0 {
convStr := Messages(conv).String()
if err := a.options.ragdb.Store(convStr); err != nil {
xlog.Error("Error storing whole conversation into memory", "error", err)
}
}
case StoreUserAndAssistant:
// Store user and assistant messages separately
for _, message := range conv {
if message.Role == "user" || message.Role == "assistant" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing message into memory", "error", err, "role", message.Role)
}
}
}
case StoreUserOnly:
fallthrough
default:
// Store only user messages (default behavior)
for _, message := range conv {
if message.Role == "user" {
if err := a.options.ragdb.Store(message.Content); err != nil {
xlog.Error("Error storing into memory", "error", err)
}
}
}
}
}
}
// KBWrapperActions wraps RAGDB functionality as actions
type KBWrapperActions struct {
ragdb RAGDB
kbResults int
}
type SearchKnowledgeBaseAction struct {
*KBWrapperActions
}
type AddToKnowledgeBaseAction struct {
*KBWrapperActions
}
// NewKBWrapperActions creates factory functions for KB wrapper actions
func NewKBWrapperActions(ragdb RAGDB, kbResults int) (*SearchKnowledgeBaseAction, *AddToKnowledgeBaseAction) {
wrapper := &KBWrapperActions{
ragdb: ragdb,
kbResults: kbResults,
}
return &SearchKnowledgeBaseAction{wrapper}, &AddToKnowledgeBaseAction{wrapper}
}
func (a *SearchKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if a.ragdb == nil {
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
}
var req struct {
Query string `json:"query"`
}
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Query == "" {
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
}
results, err := a.ragdb.Search(req.Query, a.kbResults)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search knowledge base: %w", err)
}
if len(results) == 0 {
return types.ActionResult{
Result: fmt.Sprintf("No results found for query: %q", req.Query),
}, nil
}
formatResults := ""
for i, r := range results {
formatResults += fmt.Sprintf("%d. %s\n", i+1, r)
}
return types.ActionResult{
Result: fmt.Sprintf("Found %d result(s) for query %q:\n%s", len(results), req.Query, formatResults),
Metadata: map[string]interface{}{
"query": req.Query,
"results": results,
"count": len(results),
},
}, nil
}
func (a *SearchKnowledgeBaseAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: types.ActionDefinitionName("search_memory"),
Description: "Search your memory for relevant information using a query string",
Properties: map[string]jsonschema.Definition{
"query": {
Type: jsonschema.String,
Description: "The search query to find relevant information in the knowledge base",
},
},
Required: []string{"query"},
}
}
func (a *AddToKnowledgeBaseAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if a.ragdb == nil {
return types.ActionResult{}, fmt.Errorf("knowledge base is not configured")
}
var req struct {
Content string `json:"content"`
}
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Content == "" {
return types.ActionResult{}, fmt.Errorf("content cannot be empty")
}
if err := a.ragdb.Store(req.Content); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to store content in knowledge base: %w", err)
}
return types.ActionResult{
Result: "Successfully added content to knowledge base",
Metadata: map[string]interface{}{
"content": req.Content,
},
}, nil
}
func (a *AddToKnowledgeBaseAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: types.ActionDefinitionName("add_memory"),
Description: "Add new content to your memory for future retrieval",
Properties: map[string]jsonschema.Definition{
"content": {
Type: jsonschema.String,
Description: "The content to store in the knowledge base",
},
},
Required: []string{"content"},
}
}
+13 -11
View File
@@ -25,6 +25,7 @@ type MCPServer struct {
}
type MCPSTDIOServer struct {
Name string `json:"name,omitempty"`
Args []string `json:"args"`
Env []string `json:"env"`
Cmd string `json:"cmd"`
@@ -136,7 +137,7 @@ func newBearerTokenRoundTripper(token string, base http.RoundTripper) http.Round
}
func (a *Agent) initMCPActions() error {
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
a.closeMCPServers() // Make sure we stop all previous servers if any is active
a.mcpActionDefinitions = nil
var err error
@@ -154,13 +155,17 @@ func (a *Agent) initMCPActions() error {
Transport: newBearerTokenRoundTripper(mcpServer.Token, http.DefaultTransport),
}
transport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
// Create a new client
session, err := client.Connect(a.context, transport, nil)
streamableTransport := &mcp.StreamableClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
session, err := client.Connect(a.context, streamableTransport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server", "server", mcpServer, "error", err.Error())
continue
xlog.Error("Failed to connect to MCP server via StreamableClientTransport", "server", mcpServer, "error", err.Error())
sseTransport := &mcp.SSEClientTransport{HTTPClient: httpclient, Endpoint: mcpServer.URL}
session, err = client.Connect(a.context, sseTransport, nil)
if err != nil {
xlog.Error("Failed to connect to MCP server via SSEClientTransport", "server", mcpServer, "error", err.Error())
continue
}
}
a.mcpSessions = append(a.mcpSessions, session)
@@ -173,9 +178,6 @@ func (a *Agent) initMCPActions() error {
}
// MCP STDIO Servers
a.closeMCPSTDIOServers() // Make sure we stop all previous servers if any is active
if a.options.mcpPrepareScript != "" {
xlog.Debug("Preparing MCP", "script", a.options.mcpPrepareScript)
@@ -214,7 +216,7 @@ func (a *Agent) initMCPActions() error {
return err
}
func (a *Agent) closeMCPSTDIOServers() {
func (a *Agent) closeMCPServers() {
for _, s := range a.mcpSessions {
s.Close()
}
+85 -7
View File
@@ -6,16 +6,28 @@ import (
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/sashabaranov/go-openai"
)
type Option func(*options) error
// ConversationStorageMode defines how conversations are stored in the knowledge base
type ConversationStorageMode string
const (
// StoreUserOnly stores only user messages (default)
StoreUserOnly ConversationStorageMode = "user_only"
// StoreUserAndAssistant stores both user and assistant messages separately
StoreUserAndAssistant ConversationStorageMode = "user_and_assistant"
// StoreWholeConversation stores the entire conversation as a single block
StoreWholeConversation ConversationStorageMode = "whole_conversation"
)
type llmOptions struct {
APIURL string
APIKey string
Model string
MultimodalModel string
ReviewerModel string
TranscriptionModel string
TranscriptionLanguage string
TTSModel string
@@ -30,17 +42,23 @@ type options struct {
jobFilters types.JobFilters
enableHUD, standaloneJob, showCharacter, enableKB, enableSummaryMemory, enableLongTermMemory bool
stripThinkingTags bool
kbAutoSearch bool
conversationStorageMode ConversationStorageMode
canStopItself bool
initiateConversations bool
forceReasoning bool
enableGuidedTools bool
canPlan bool
disableSinkState bool
characterfile string
statefile string
schedulerStorePath string // Path to scheduler JSON storage file
context context.Context
permanentGoal string
timeout string
periodicRuns time.Duration
schedulerPollInterval time.Duration
kbResults int
ragdb RAGDB
@@ -61,7 +79,7 @@ type options struct {
mcpServers []MCPServer
mcpStdioServers []MCPSTDIOServer
mcpPrepareScript string
newConversationsSubscribers []func(openai.ChatCompletionMessage)
newConversationsSubscribers []func(*types.ConversationMessage)
observer Observer
parallelJobs int
@@ -75,10 +93,13 @@ func (o *options) SeparatedMultimodalModel() bool {
func defaultOptions() *options {
return &options{
parallelJobs: 1,
periodicRuns: 15 * time.Minute,
maxEvaluationLoops: 2,
enableEvaluation: false,
parallelJobs: 1,
periodicRuns: 15 * time.Minute,
schedulerPollInterval: 30 * time.Second,
maxEvaluationLoops: 2,
enableEvaluation: false,
kbAutoSearch: true, // Default to true to maintain backward compatibility
conversationStorageMode: StoreUserOnly, // Default to user-only for backward compatibility
LLMAPI: llmOptions{
APIURL: "http://localhost:8080",
Model: "gpt-4",
@@ -116,6 +137,11 @@ var EnableForceReasoning = func(o *options) error {
return nil
}
var EnableGuidedTools = func(o *options) error {
o.enableGuidedTools = true
return nil
}
var EnableKnowledgeBase = func(o *options) error {
o.enableKB = true
o.kbResults = 5
@@ -167,7 +193,7 @@ func WithParallelJobs(jobs int) Option {
}
}
func WithNewConversationSubscriber(sub func(openai.ChatCompletionMessage)) Option {
func WithNewConversationSubscriber(sub func(*types.ConversationMessage)) Option {
return func(o *options) error {
o.newConversationsSubscribers = append(o.newConversationsSubscribers, sub)
return nil
@@ -184,6 +210,18 @@ var EnablePlanning = func(o *options) error {
return nil
}
var DisableSinkState = func(o *options) error {
o.disableSinkState = true
return nil
}
var WithPlanReviewerLLM = func(model string) Option {
return func(o *options) error {
o.LLMAPI.ReviewerModel = model
return nil
}
}
// EnableStandaloneJob is an option to enable the agent
// to run jobs in the background automatically
var EnableStandaloneJob = func(o *options) error {
@@ -213,6 +251,19 @@ func WithRAGDB(db RAGDB) Option {
}
}
// WithConversationStorageMode sets how conversations are stored in the knowledge base
func WithConversationStorageMode(mode ConversationStorageMode) Option {
return func(o *options) error {
switch mode {
case StoreUserOnly, StoreUserAndAssistant, StoreWholeConversation:
o.conversationStorageMode = mode
default:
o.conversationStorageMode = StoreUserOnly
}
return nil
}
}
func WithSystemPrompt(prompt string) Option {
return func(o *options) error {
o.systemPrompt = prompt
@@ -320,6 +371,18 @@ func WithPeriodicRuns(duration string) Option {
}
}
func WithSchedulerPollInterval(duration string) Option {
return func(o *options) error {
t, err := time.ParseDuration(duration)
if err != nil {
o.schedulerPollInterval = 30 * time.Second
return nil
}
o.schedulerPollInterval = t
return nil
}
}
func WithContext(ctx context.Context) Option {
return func(o *options) error {
o.context = ctx
@@ -435,3 +498,18 @@ func WithTTSModel(model string) Option {
return nil
}
}
func WithKBAutoSearch(enabled bool) Option {
return func(o *options) error {
o.kbAutoSearch = enabled
return nil
}
}
// WithSchedulerStorePath sets the path for the scheduler's JSON storage file
func WithSchedulerStorePath(path string) Option {
return func(o *options) error {
o.schedulerStorePath = path
return nil
}
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"context"
"fmt"
"github.com/mudler/LocalAGI/core/scheduler"
"github.com/mudler/LocalAGI/core/types"
)
// agentSchedulerExecutor implements scheduler.AgentExecutor for executing scheduled tasks through the agent
type agentSchedulerExecutor struct {
agent *Agent
}
// Execute processes a scheduled task by creating a job for the agent
func (e *agentSchedulerExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
// Create a job for the reminder
reminderJob := types.NewJob(
types.WithText(fmt.Sprintf("You need to execute the following task, by using the tools available to you. When the task is completed, you need to send a message to the user with send_message tool to inform them that the task is completed: %s", prompt)),
types.WithReasoningCallback(e.agent.options.reasoningCallback),
types.WithResultCallback(e.agent.options.resultCallback),
types.WithContext(ctx),
types.WithMetadata(map[string]any{
"message": prompt,
"is_reminder": true,
"type": "scheduled",
}),
)
// Attach observable so UI can show reminder processing state
if e.agent.observer != nil {
obs := e.agent.observer.NewObservable()
obs.Name = "reminder"
obs.Icon = "bell"
e.agent.observer.Update(*obs)
reminderJob.Obs = obs
}
// Send the job to be processed
e.agent.jobQueue <- reminderJob
// Wait for the job to complete or context to be cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
result, err := reminderJob.Result.WaitResult(ctx)
if err != nil {
return nil, err
}
if result.Error != nil {
return &scheduler.JobResult{
Response: "",
Error: result.Error,
}, result.Error
}
return &scheduler.JobResult{
Response: result.Response,
Error: nil,
}, nil
}
}
+49
View File
@@ -0,0 +1,49 @@
package scheduler
import (
"context"
)
// TaskStore defines the interface for task persistence
type TaskStore interface {
// Create adds a new task
Create(task *Task) error
// Get retrieves a task by ID
Get(id string) (*Task, error)
// GetAll retrieves all tasks
GetAll() ([]*Task, error)
// GetDue retrieves tasks that are due for execution
GetDue() ([]*Task, error)
// GetByAgent retrieves all tasks for a specific agent
GetByAgent(agentName string) ([]*Task, error)
// Update updates an existing task
Update(task *Task) error
// Delete removes a task
Delete(id string) error
// LogRun records a task execution
LogRun(run *TaskRun) error
// GetRuns retrieves execution history for a task
GetRuns(taskID string, limit int) ([]*TaskRun, error)
// Close releases resources
Close() error
}
// AgentExecutor defines the interface for executing agent tasks
type AgentExecutor interface {
Execute(ctx context.Context, agentName string, prompt string) (*JobResult, error)
}
// JobResult represents the result of an agent execution
type JobResult struct {
Response string
Error error
}
+219
View File
@@ -0,0 +1,219 @@
package scheduler
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// JSONStore implements TaskStore using JSON file storage
type JSONStore struct {
filePath string
mu sync.RWMutex
data *storeData
}
type storeData struct {
Tasks []*Task `json:"tasks"`
TaskRuns []*TaskRun `json:"task_runs"`
}
// NewJSONStore creates a new JSON-based task store
func NewJSONStore(filePath string) (*JSONStore, error) {
store := &JSONStore{
filePath: filePath,
data: &storeData{
Tasks: make([]*Task, 0),
TaskRuns: make([]*TaskRun, 0),
},
}
if err := store.load(); err != nil {
if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load store: %w", err)
}
// File doesn't exist, create it
if err := store.save(); err != nil {
return nil, fmt.Errorf("failed to create store file: %w", err)
}
}
return store, nil
}
// Create adds a new task
func (s *JSONStore) Create(task *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check for duplicate ID
for _, t := range s.data.Tasks {
if t.ID == task.ID {
return fmt.Errorf("task with ID %s already exists", task.ID)
}
}
s.data.Tasks = append(s.data.Tasks, task)
return s.save()
}
// Get retrieves a task by ID
func (s *JSONStore) Get(id string) (*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, task := range s.data.Tasks {
if task.ID == id {
return task, nil
}
}
return nil, fmt.Errorf("task not found: %s", id)
}
// GetAll retrieves all tasks
func (s *JSONStore) GetAll() ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
// Return a copy to prevent external modification
tasks := make([]*Task, len(s.data.Tasks))
copy(tasks, s.data.Tasks)
return tasks, nil
}
// GetDue retrieves tasks that are due for execution
func (s *JSONStore) GetDue() ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
now := time.Now()
dueTasks := make([]*Task, 0)
for _, task := range s.data.Tasks {
if task.Status == TaskStatusActive && now.After(task.NextRun) {
dueTasks = append(dueTasks, task)
}
}
return dueTasks, nil
}
// GetByAgent retrieves all tasks for a specific agent
func (s *JSONStore) GetByAgent(agentName string) ([]*Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
agentTasks := make([]*Task, 0)
for _, task := range s.data.Tasks {
if task.AgentName == agentName {
agentTasks = append(agentTasks, task)
}
}
return agentTasks, nil
}
// Update updates an existing task
func (s *JSONStore) Update(task *Task) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, t := range s.data.Tasks {
if t.ID == task.ID {
task.UpdatedAt = time.Now()
s.data.Tasks[i] = task
return s.save()
}
}
return fmt.Errorf("task not found: %s", task.ID)
}
// Delete removes a task
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, task := range s.data.Tasks {
if task.ID == id {
// Remove task from slice
s.data.Tasks = append(s.data.Tasks[:i], s.data.Tasks[i+1:]...)
return s.save()
}
}
return fmt.Errorf("task not found: %s", id)
}
// LogRun records a task execution
func (s *JSONStore) LogRun(run *TaskRun) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data.TaskRuns = append(s.data.TaskRuns, run)
return s.save()
}
// GetRuns retrieves execution history for a task
func (s *JSONStore) GetRuns(taskID string, limit int) ([]*TaskRun, error) {
s.mu.RLock()
defer s.mu.RUnlock()
runs := make([]*TaskRun, 0)
for i := len(s.data.TaskRuns) - 1; i >= 0 && len(runs) < limit; i-- {
if s.data.TaskRuns[i].TaskID == taskID {
runs = append(runs, s.data.TaskRuns[i])
}
}
return runs, nil
}
// Close releases resources (no-op for JSON store)
func (s *JSONStore) Close() error {
return nil
}
// load reads data from the JSON file
func (s *JSONStore) load() error {
file, err := os.ReadFile(s.filePath)
if err != nil {
return err
}
// Handle empty file
if len(file) == 0 {
return nil
}
if err := json.Unmarshal(file, s.data); err != nil {
return err
}
// Ensure slices are not nil after unmarshaling
if s.data.Tasks == nil {
s.data.Tasks = make([]*Task, 0)
}
if s.data.TaskRuns == nil {
s.data.TaskRuns = make([]*TaskRun, 0)
}
return nil
}
// save writes data to the JSON file
func (s *JSONStore) save() error {
data, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
basePath := filepath.Dir(s.filePath)
os.MkdirAll(basePath, 0755)
return os.WriteFile(s.filePath, data, 0644)
}
+249
View File
@@ -0,0 +1,249 @@
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"github.com/mudler/xlog"
)
// Scheduler manages scheduled tasks
type Scheduler struct {
store TaskStore
executor AgentExecutor
pollInterval time.Duration
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
mu sync.RWMutex
runningTasks map[string]context.CancelFunc
}
// NewScheduler creates a new scheduler with the given store and executor
func NewScheduler(store TaskStore, executor AgentExecutor, pollInterval time.Duration) *Scheduler {
return &Scheduler{
store: store,
executor: executor,
pollInterval: pollInterval,
runningTasks: make(map[string]context.CancelFunc),
}
}
// Start begins the scheduler's polling loop
func (s *Scheduler) Start() {
if s.ctx != nil {
xlog.Warn("Scheduler already started")
return
}
ctx, cancel := context.WithCancel(context.Background())
s.ctx = ctx
s.cancel = cancel
s.wg.Add(1)
go s.run()
xlog.Info("Task scheduler started", "poll_interval", s.pollInterval)
}
// Stop gracefully stops the scheduler
func (s *Scheduler) Stop() {
if s.cancel != nil {
s.cancel()
}
s.wg.Wait()
s.store.Close()
xlog.Info("Task scheduler stopped")
s.cancel = nil
s.ctx = nil
}
// run is the main polling loop
func (s *Scheduler) run() {
defer s.wg.Done()
ticker := time.NewTicker(s.pollInterval)
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
s.processDueTasks()
}
}
}
// processDueTasks checks for and executes due tasks
func (s *Scheduler) processDueTasks() {
tasks, err := s.store.GetDue()
if err != nil {
xlog.Error("Failed to get due tasks", "error", err)
return
}
if len(tasks) > 0 {
xlog.Debug("Processing due tasks", "count", len(tasks))
}
for _, task := range tasks {
// Check if task is already running
s.mu.RLock()
_, running := s.runningTasks[task.ID]
s.mu.RUnlock()
if running {
xlog.Warn("Task already running, skipping", "task_id", task.ID)
continue
}
// Execute task in goroutine
s.wg.Add(1)
go s.executeTask(task)
}
}
// executeTask runs a single task
func (s *Scheduler) executeTask(task *Task) {
defer s.wg.Done()
taskCtx, cancel := context.WithCancel(s.ctx)
defer cancel()
// Register running task
s.mu.Lock()
s.runningTasks[task.ID] = cancel
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.runningTasks, task.ID)
s.mu.Unlock()
}()
xlog.Info("Executing task", "task_id", task.ID, "agent", task.AgentName, "prompt", task.Prompt)
startTime := time.Now()
run := NewTaskRun(task.ID)
// Execute the task
result, err := s.executor.Execute(taskCtx, task.AgentName, task.Prompt)
run.DurationMs = time.Since(startTime).Milliseconds()
if err != nil {
run.Status = "error"
run.Error = err.Error()
xlog.Error("Task execution failed", "task_id", task.ID, "error", err)
} else {
run.Status = "success"
if result != nil {
run.Result = result.Response
}
xlog.Info("Task executed successfully", "task_id", task.ID, "duration_ms", run.DurationMs)
}
// Log the run
if err := s.store.LogRun(run); err != nil {
xlog.Error("Failed to log task run", "task_id", task.ID, "error", err)
}
// Update task for next run
now := time.Now()
task.LastRun = &now
// For one-time tasks, mark as deleted
if task.ScheduleType == ScheduleTypeOnce {
if err := s.store.Delete(task.ID); err != nil {
xlog.Error("Failed to delete task", "task_id", task.ID, "error", err)
}
} else {
// Calculate next run
if err := task.CalculateNextRun(); err != nil {
xlog.Error("Failed to calculate next run", "task_id", task.ID, "error", err)
task.Status = TaskStatusPaused
}
}
if err := s.store.Update(task); err != nil {
xlog.Error("Failed to update task", "task_id", task.ID, "error", err)
}
}
// CRUD operations
// CreateTask adds a new task
func (s *Scheduler) CreateTask(task *Task) error {
return s.store.Create(task)
}
// GetTask retrieves a task by ID
func (s *Scheduler) GetTask(id string) (*Task, error) {
return s.store.Get(id)
}
// GetAllTasks retrieves all tasks
func (s *Scheduler) GetAllTasks() ([]*Task, error) {
return s.store.GetAll()
}
// GetTasksByAgent retrieves all tasks for a specific agent
func (s *Scheduler) GetTasksByAgent(agentName string) ([]*Task, error) {
return s.store.GetByAgent(agentName)
}
// UpdateTask updates an existing task
func (s *Scheduler) UpdateTask(task *Task) error {
return s.store.Update(task)
}
// DeleteTask removes a task
func (s *Scheduler) DeleteTask(id string) error {
return s.store.Delete(id)
}
// GetTaskRuns retrieves execution history for a task
func (s *Scheduler) GetTaskRuns(taskID string, limit int) ([]*TaskRun, error) {
return s.store.GetRuns(taskID, limit)
}
// PauseTask pauses a task
func (s *Scheduler) PauseTask(id string) error {
task, err := s.store.Get(id)
if err != nil {
return err
}
task.Status = TaskStatusPaused
return s.store.Update(task)
}
// ResumeTask resumes a paused task
func (s *Scheduler) ResumeTask(id string) error {
task, err := s.store.Get(id)
if err != nil {
return err
}
task.Status = TaskStatusActive
if err := task.CalculateNextRun(); err != nil {
return err
}
return s.store.Update(task)
}
// CancelRunningTask cancels a currently running task
func (s *Scheduler) CancelRunningTask(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
cancel, exists := s.runningTasks[id]
if !exists {
return fmt.Errorf("task not running: %s", id)
}
cancel()
return nil
}
+13
View File
@@ -0,0 +1,13 @@
package scheduler_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestScheduler(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Scheduler Suite")
}
+395
View File
@@ -0,0 +1,395 @@
package scheduler_test
import (
"context"
"errors"
"os"
"time"
"github.com/mudler/LocalAGI/core/scheduler"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// MockExecutor for testing
type MockExecutor struct {
executedTasks []string
shouldError bool
}
func (m *MockExecutor) Execute(ctx context.Context, agentName string, prompt string) (*scheduler.JobResult, error) {
m.executedTasks = append(m.executedTasks, agentName+":"+prompt)
if m.shouldError {
return nil, errors.New("mock execution error")
}
return &scheduler.JobResult{Response: "test response"}, nil
}
var _ = Describe("Scheduler", func() {
var (
tempFile string
store scheduler.TaskStore
executor *MockExecutor
sched *scheduler.Scheduler
)
BeforeEach(func() {
// Create temporary file for JSON store
f, err := os.CreateTemp("", "scheduler_test_*.json")
Expect(err).NotTo(HaveOccurred())
tempFile = f.Name()
f.Close()
store, err = scheduler.NewJSONStore(tempFile)
Expect(err).NotTo(HaveOccurred())
executor = &MockExecutor{}
sched = scheduler.NewScheduler(store, executor, 100*time.Millisecond)
sched.Start()
})
AfterEach(func() {
if sched != nil {
sched.Stop()
}
os.Remove(tempFile)
})
Describe("Task Creation", func() {
It("should create a valid task with cron schedule", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
Expect(task.ID).NotTo(BeEmpty())
Expect(task.AgentName).To(Equal("test-agent"))
Expect(task.Prompt).To(Equal("test prompt"))
Expect(task.ScheduleType).To(Equal(scheduler.ScheduleTypeCron))
Expect(task.Status).To(Equal(scheduler.TaskStatusActive))
Expect(task.NextRun).NotTo(BeZero())
})
It("should return error for invalid cron expression", func() {
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "invalid cron")
Expect(err).To(HaveOccurred())
})
It("should create a valid task with interval schedule", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeInterval, "3600000")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using duration", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "24h")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using day syntax", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "1d")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(24*time.Hour), 5*time.Second))
})
It("should create a valid task with once schedule using combined day+time", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "2d12h30m")
Expect(err).NotTo(HaveOccurred())
Expect(task.NextRun).To(BeTemporally("~", time.Now().Add(2*24*time.Hour+12*time.Hour+30*time.Minute), 5*time.Second))
})
It("should return error for invalid once duration", func() {
_, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "invalid")
Expect(err).To(HaveOccurred())
})
})
Describe("Task IsDue", func() {
It("should return true for active task past due time", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusActive,
NextRun: time.Now().Add(-1 * time.Hour),
}
Expect(task.IsDue()).To(BeTrue())
})
It("should return false for active task not yet due", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusActive,
NextRun: time.Now().Add(1 * time.Hour),
}
Expect(task.IsDue()).To(BeFalse())
})
It("should return false for paused task even if past due", func() {
task := &scheduler.Task{
Status: scheduler.TaskStatusPaused,
NextRun: time.Now().Add(-1 * time.Hour),
}
Expect(task.IsDue()).To(BeFalse())
})
})
Describe("JSON Store", func() {
Context("CRUD operations", func() {
It("should create and retrieve a task", func() {
task, err := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
err = store.Create(task)
Expect(err).NotTo(HaveOccurred())
retrieved, err := store.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.ID).To(Equal(task.ID))
Expect(retrieved.AgentName).To(Equal(task.AgentName))
Expect(retrieved.Prompt).To(Equal(task.Prompt))
})
It("should update a task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
task.Prompt = "updated prompt"
err := store.Update(task)
Expect(err).NotTo(HaveOccurred())
updated, err := store.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(updated.Prompt).To(Equal("updated prompt"))
})
It("should delete a task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
err := store.Delete(task.ID)
Expect(err).NotTo(HaveOccurred())
_, err = store.Get(task.ID)
Expect(err).To(HaveOccurred())
})
It("should return error when getting non-existent task", func() {
_, err := store.Get("non-existent-id")
Expect(err).To(HaveOccurred())
})
})
Context("Querying tasks", func() {
BeforeEach(func() {
// Create test tasks
// task1: once schedule with 0s delay => immediately due
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeOnce, "0s")
Expect(err).NotTo(HaveOccurred())
task1.NextRun = time.Now().Add(-1 * time.Hour) // force into the past
// task2: cron schedule => next run in the future, not due
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 1 1 *")
Expect(err).NotTo(HaveOccurred())
// task3: once schedule but paused => not due
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeOnce, "0s")
Expect(err).NotTo(HaveOccurred())
task3.Status = scheduler.TaskStatusPaused
Expect(store.Create(task1)).To(Succeed())
Expect(store.Create(task2)).To(Succeed())
Expect(store.Create(task3)).To(Succeed())
})
It("should get all tasks", func() {
tasks, err := store.GetAll()
Expect(err).NotTo(HaveOccurred())
Expect(tasks).To(HaveLen(3))
})
It("should get only due tasks", func() {
dueTasks, err := store.GetDue()
Expect(err).NotTo(HaveOccurred())
Expect(dueTasks).To(HaveLen(1))
Expect(dueTasks[0].AgentName).To(Equal("agent1"))
Expect(dueTasks[0].Prompt).To(Equal("prompt1"))
})
It("should get tasks by agent", func() {
agentTasks, err := store.GetByAgent("agent1")
Expect(err).NotTo(HaveOccurred())
Expect(agentTasks).To(HaveLen(2))
})
})
Context("Task runs", func() {
It("should log and retrieve task runs", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
run := scheduler.NewTaskRun(task.ID)
run.Status = "success"
run.Result = "test result"
run.DurationMs = 1000
err := store.LogRun(run)
Expect(err).NotTo(HaveOccurred())
runs, err := store.GetRuns(task.ID, 10)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(1))
Expect(runs[0].Status).To(Equal("success"))
Expect(runs[0].Result).To(Equal("test result"))
})
It("should limit returned runs", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
// Create 5 runs
for i := 0; i < 5; i++ {
run := scheduler.NewTaskRun(task.ID)
store.LogRun(run)
}
runs, err := store.GetRuns(task.ID, 3)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(3))
})
})
Context("Persistence", func() {
It("should persist data across store instances", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeCron, "0 0 * * *")
store.Create(task)
store.Close()
// Create new store instance with same file
newStore, err := scheduler.NewJSONStore(tempFile)
Expect(err).NotTo(HaveOccurred())
defer newStore.Close()
retrieved, err := newStore.Get(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(retrieved.ID).To(Equal(task.ID))
})
})
})
Describe("Scheduler Execution", func() {
It("should execute a due task", func() {
task, _ := scheduler.NewTask("test-agent", "test prompt", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
err := sched.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Scheduler is already started in BeforeEach
Eventually(func() int {
return len(executor.executedTasks)
}, "2s", "100ms").Should(Equal(1))
Expect(executor.executedTasks[0]).To(Equal("test-agent:test prompt"))
// Verify task run was logged
runs, err := sched.GetTaskRuns(task.ID, 10)
Expect(err).NotTo(HaveOccurred())
Expect(runs).To(HaveLen(1))
Expect(runs[0].Status).To(Equal("success"))
// Verify one-time task was deleted
_, err = sched.GetTask(task.ID)
Expect(err).To(HaveOccurred())
})
It("should execute recurring tasks multiple times", func() {
task, _ := scheduler.NewTask("test-agent", "recurring", scheduler.ScheduleTypeInterval, "500")
task.NextRun = time.Now().Add(-1 * time.Second)
err := sched.CreateTask(task)
Expect(err).NotTo(HaveOccurred())
// Scheduler is already started in BeforeEach
Eventually(func() int {
return len(executor.executedTasks)
}, "3s", "100ms").Should(BeNumerically(">=", 2))
// Verify task is still active
updatedTask, err := sched.GetTask(task.ID)
Expect(err).NotTo(HaveOccurred())
Expect(updatedTask.Status).To(Equal(scheduler.TaskStatusActive))
})
It("should handle task execution errors", func() {
executor.shouldError = true
task, _ := scheduler.NewTask("test-agent", "error task", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
sched.CreateTask(task)
// Scheduler is already started in BeforeEach
Eventually(func() int {
runs, _ := sched.GetTaskRuns(task.ID, 10)
return len(runs)
}, "2s", "100ms").Should(Equal(1))
runs, _ := sched.GetTaskRuns(task.ID, 10)
Expect(runs[0].Status).To(Equal("error"))
Expect(runs[0].Error).NotTo(BeEmpty())
})
It("should not execute paused tasks", func() {
task, _ := scheduler.NewTask("test-agent", "paused", scheduler.ScheduleTypeOnce, "0s")
task.NextRun = time.Now().Add(-1 * time.Second) // force into the past
task.Status = scheduler.TaskStatusPaused
sched.CreateTask(task)
// Scheduler is already started in BeforeEach
Consistently(func() int {
return len(executor.executedTasks)
}, "1s", "100ms").Should(Equal(0))
})
})
Describe("Task Management", func() {
It("should pause and resume a task", func() {
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
sched.CreateTask(task)
err := sched.PauseTask(task.ID)
Expect(err).NotTo(HaveOccurred())
paused, _ := sched.GetTask(task.ID)
Expect(paused.Status).To(Equal(scheduler.TaskStatusPaused))
err = sched.ResumeTask(task.ID)
Expect(err).NotTo(HaveOccurred())
resumed, _ := sched.GetTask(task.ID)
Expect(resumed.Status).To(Equal(scheduler.TaskStatusActive))
Expect(resumed.NextRun).NotTo(BeZero())
})
It("should get tasks by agent", func() {
task1, err := scheduler.NewTask("agent1", "prompt1", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
task2, err := scheduler.NewTask("agent2", "prompt2", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
task3, err := scheduler.NewTask("agent1", "prompt3", scheduler.ScheduleTypeCron, "0 0 * * *")
Expect(err).NotTo(HaveOccurred())
Expect(sched.CreateTask(task1)).To(Succeed())
Expect(sched.CreateTask(task2)).To(Succeed())
Expect(sched.CreateTask(task3)).To(Succeed())
agent1Tasks, err := sched.GetTasksByAgent("agent1")
Expect(err).NotTo(HaveOccurred())
Expect(agent1Tasks).To(HaveLen(2))
})
It("should delete a task", func() {
task, _ := scheduler.NewTask("test-agent", "test", scheduler.ScheduleTypeCron, "0 0 * * *")
sched.CreateTask(task)
err := sched.DeleteTask(task.ID)
Expect(err).NotTo(HaveOccurred())
_, err = sched.GetTask(task.ID)
Expect(err).To(HaveOccurred())
})
})
})
+156
View File
@@ -0,0 +1,156 @@
package scheduler
import (
"fmt"
"regexp"
"strconv"
"time"
"github.com/google/uuid"
"github.com/robfig/cron/v3"
)
var dayPattern = regexp.MustCompile(`^(\d+)d(.*)$`)
// ParseDuration extends time.ParseDuration with support for days ("d").
// Examples: "1d" = 24h, "2d12h" = 60h, "30m", "2h30m".
func ParseDuration(s string) (time.Duration, error) {
if m := dayPattern.FindStringSubmatch(s); m != nil {
days, err := strconv.Atoi(m[1])
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
d := time.Duration(days) * 24 * time.Hour
if m[2] != "" {
rest, err := time.ParseDuration(m[2])
if err != nil {
return 0, fmt.Errorf("invalid duration: %w", err)
}
d += rest
}
return d, nil
}
return time.ParseDuration(s)
}
type TaskStatus string
const (
TaskStatusActive TaskStatus = "active"
TaskStatusPaused TaskStatus = "paused"
)
type ScheduleType string
const (
ScheduleTypeCron ScheduleType = "cron"
ScheduleTypeInterval ScheduleType = "interval"
ScheduleTypeOnce ScheduleType = "once"
)
// Task represents a scheduled task
type Task struct {
ID string `json:"id"`
AgentName string `json:"agent_name"`
Prompt string `json:"prompt"`
ScheduleType ScheduleType `json:"schedule_type"`
ScheduleValue string `json:"schedule_value"`
Status TaskStatus `json:"status"`
NextRun time.Time `json:"next_run"`
LastRun *time.Time `json:"last_run,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ContextMode string `json:"context_mode"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// TaskRun represents a single execution of a task
type TaskRun struct {
ID string `json:"id"`
TaskID string `json:"task_id"`
RunAt time.Time `json:"run_at"`
DurationMs int64 `json:"duration_ms"`
Status string `json:"status"` // "success", "error", "timeout"
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// NewTask creates a new task with the given parameters
func NewTask(agentName, prompt string, scheduleType ScheduleType, scheduleValue string) (*Task, error) {
task := &Task{
ID: uuid.New().String(),
AgentName: agentName,
Prompt: prompt,
ScheduleType: scheduleType,
ScheduleValue: scheduleValue,
Status: TaskStatusActive,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
ContextMode: "agent",
Metadata: make(map[string]interface{}),
}
if err := task.CalculateNextRun(); err != nil {
return nil, err
}
return task, nil
}
// CalculateNextRun calculates the next run time based on schedule type
func (t *Task) CalculateNextRun() error {
now := time.Now()
switch t.ScheduleType {
case ScheduleTypeCron:
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
schedule, err := parser.Parse(t.ScheduleValue)
if err != nil {
return fmt.Errorf("invalid cron expression: %w", err)
}
t.NextRun = schedule.Next(now)
case ScheduleTypeInterval:
intervalMs, err := strconv.ParseInt(t.ScheduleValue, 10, 64)
if err != nil {
return fmt.Errorf("invalid interval: %w", err)
}
if intervalMs <= 0 {
return fmt.Errorf("invalid interval: %d", intervalMs)
}
if t.LastRun != nil {
t.NextRun = t.LastRun.Add(time.Duration(intervalMs) * time.Millisecond)
} else {
t.NextRun = now.Add(time.Duration(intervalMs) * time.Millisecond)
}
case ScheduleTypeOnce:
duration, err := ParseDuration(t.ScheduleValue)
if err != nil {
return fmt.Errorf("invalid duration: %w", err)
}
if duration < 0 {
return fmt.Errorf("duration must be positive: %s", t.ScheduleValue)
}
t.NextRun = now.Add(duration)
default:
return fmt.Errorf("unknown schedule type: %s", t.ScheduleType)
}
return nil
}
// IsDue checks if the task should be executed now
func (t *Task) IsDue() bool {
return t.Status == TaskStatusActive && time.Now().After(t.NextRun)
}
// NewTaskRun creates a new task run record
func NewTaskRun(taskID string) *TaskRun {
return &TaskRun{
ID: uuid.New().String(),
TaskID: taskID,
RunAt: time.Now(),
}
}
+213
View File
@@ -0,0 +1,213 @@
package state
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai"
)
// datePrefixRegex matches YYYY-MM-DD at the start of a filename (e.g. 2006-01-02-15-04-05-hash.txt).
var datePrefixRegex = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
// summaryPrefix is the filename prefix for compaction summary entries; skip re-compacting these.
const summaryPrefix = "summary-"
// bucketKey returns the period bucket key for a date string (YYYY-MM-DD).
func bucketKey(dateStr, period string) (string, error) {
t, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return "", err
}
switch period {
case "daily":
return dateStr, nil
case "weekly":
year, week := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week), nil
case "monthly":
return t.Format("2006-01"), nil
default:
return dateStr, nil
}
}
// dateFromFilename extracts YYYY-MM-DD from the start of a filename if present.
func dateFromFilename(filename string) (string, bool) {
base := filepath.Base(filename)
matches := datePrefixRegex.FindStringSubmatch(base)
if len(matches) < 2 {
return "", false
}
return matches[1], true
}
// groupEntriesByPeriod groups entry names by period bucket (daily/weekly/monthly). Skips summary-* and entries without a parseable date.
func groupEntriesByPeriod(entries []string, period string) map[string][]string {
groups := make(map[string][]string)
for _, entry := range entries {
if strings.HasPrefix(filepath.Base(entry), summaryPrefix) {
continue
}
dateStr, ok := dateFromFilename(entry)
if !ok {
continue
}
key, err := bucketKey(dateStr, period)
if err != nil {
xlog.Debug("compaction: skip entry, invalid date", "entry", entry, "error", err)
continue
}
groups[key] = append(groups[key], entry)
}
return groups
}
// summarizer summarizes text via the LLM.
type summarizer interface {
Summarize(ctx context.Context, content string) (string, error)
}
type openAISummarizer struct {
client *openai.Client
model string
}
func (s *openAISummarizer) Summarize(ctx context.Context, content string) (string, error) {
if content == "" {
return "", nil
}
resp, err := s.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: s.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: "Summarize the following knowledge base entries into a concise summary. Preserve important facts and key points."},
{Role: openai.ChatMessageRoleUser, Content: content},
},
})
if err != nil {
return "", err
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no completion choices")
}
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
}
// RunCompaction runs one compaction pass: list entries, group by period, for each group fetch content, optionally summarize, store result, delete originals.
func RunCompaction(ctx context.Context, client *localrag.WrappedClient, period string, summarize bool, apiURL, apiKey, model string) error {
collection := client.Collection()
entries, err := client.Client.ListEntries(collection)
if err != nil {
return fmt.Errorf("list entries: %w", err)
}
groups := groupEntriesByPeriod(entries, period)
if len(groups) == 0 {
xlog.Debug("compaction: no groups to compact", "collection", collection, "period", period)
return nil
}
var sum summarizer
if summarize && apiURL != "" && model != "" {
openAIClient := llm.NewClient(apiKey, apiURL, "120s")
sum = &openAISummarizer{client: openAIClient, model: model}
}
for key, groupEntries := range groups {
if len(groupEntries) == 0 {
continue
}
var combined strings.Builder
for _, entry := range groupEntries {
entryContent, _, err := client.GetEntryContent(entry)
if err != nil {
xlog.Warn("compaction: get entry content failed", "entry", entry, "error", err)
continue
}
if entryContent != "" {
combined.WriteString(entryContent)
combined.WriteString("\n\n")
}
}
content := strings.TrimSpace(combined.String())
if content == "" {
xlog.Debug("compaction: empty content for group", "key", key)
continue
}
if sum != nil {
summary, err := sum.Summarize(ctx, content)
if err != nil {
xlog.Warn("compaction: summarize failed", "key", key, "error", err)
continue
}
content = summary
}
// Store result as summary-<key>.txt
resultFilename := fmt.Sprintf("%s%s.txt", summaryPrefix, key)
tmpDir, err := os.MkdirTemp("", "localagi-compact")
if err != nil {
xlog.Warn("compaction: mkdir temp failed", "error", err)
continue
}
tmpPath := filepath.Join(tmpDir, resultFilename)
if err := os.WriteFile(tmpPath, []byte(content), 0644); err != nil {
os.RemoveAll(tmpDir)
xlog.Warn("compaction: write temp file failed", "error", err)
continue
}
if err := client.Client.Store(collection, tmpPath); err != nil {
os.RemoveAll(tmpDir)
xlog.Warn("compaction: store failed", "key", key, "error", err)
continue
}
os.RemoveAll(tmpDir)
for _, entry := range groupEntries {
if _, err := client.Client.DeleteEntry(collection, entry); err != nil {
xlog.Warn("compaction: delete entry failed", "entry", entry, "error", err)
}
}
xlog.Info("compaction: compacted group", "collection", collection, "period", period, "key", key, "entries", len(groupEntries))
}
return nil
}
// runCompactionTicker runs compaction on a schedule (daily/weekly/monthly). It stops when ctx is done.
func runCompactionTicker(ctx context.Context, client *localrag.WrappedClient, config *AgentConfig, apiURL, apiKey, model string) {
// Run first compaction immediately on startup
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
xlog.Warn("compaction ticker initial run failed", "collection", client.Collection(), "error", err)
}
interval := 24 * time.Hour
switch config.KBCompactionInterval {
case "weekly":
interval = 7 * 24 * time.Hour
case "monthly":
interval = 30 * 24 * time.Hour
default:
interval = 24 * time.Hour
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
xlog.Debug("compaction ticker stopped", "collection", client.Collection())
return
case <-ticker.C:
if err := RunCompaction(ctx, client, config.KBCompactionInterval, config.KBCompactionSummarize, apiURL, apiKey, model); err != nil {
xlog.Warn("compaction ticker failed", "collection", client.Collection(), "error", err)
}
}
}
}
+134 -7
View File
@@ -3,6 +3,7 @@ package state
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/mudler/LocalAGI/core/agent"
@@ -10,6 +11,21 @@ import (
"github.com/mudler/LocalAGI/pkg/config"
)
// parseIntField parses an integer field that may be received as either a number or a string
func parseIntField(value interface{}) int {
switch v := value.(type) {
case int:
return v
case float64:
return int(v)
case string:
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
return 0
}
type ConnectorConfig struct {
Type string `json:"type"` // e.g. Slack
Config string `json:"config"`
@@ -64,17 +80,27 @@ type AgentConfig struct {
RandomIdentity bool `json:"random_identity" form:"random_identity"`
InitiateConversations bool `json:"initiate_conversations" form:"initiate_conversations"`
CanPlan bool `json:"enable_planning" form:"enable_planning"`
PlanReviewerModel string `json:"plan_reviewer_model" form:"plan_reviewer_model"`
DisableSinkState bool `json:"disable_sink_state" form:"disable_sink_state"`
IdentityGuidance string `json:"identity_guidance" form:"identity_guidance"`
PeriodicRuns string `json:"periodic_runs" form:"periodic_runs"`
SchedulerPollInterval string `json:"scheduler_poll_interval" form:"scheduler_poll_interval"`
PermanentGoal string `json:"permanent_goal" form:"permanent_goal"`
EnableKnowledgeBase bool `json:"enable_kb" form:"enable_kb"`
EnableKBCompaction bool `json:"enable_kb_compaction" form:"enable_kb_compaction"`
KBCompactionInterval string `json:"kb_compaction_interval" form:"kb_compaction_interval"`
KBCompactionSummarize bool `json:"kb_compaction_summarize" form:"kb_compaction_summarize"`
KBAutoSearch bool `json:"kb_auto_search" form:"kb_auto_search"`
KBAsTools bool `json:"kb_as_tools" form:"kb_as_tools"`
EnableReasoning bool `json:"enable_reasoning" form:"enable_reasoning"`
EnableGuidedTools bool `json:"enable_guided_tools" form:"enable_guided_tools"`
KnowledgeBaseResults int `json:"kb_results" form:"kb_results"`
CanStopItself bool `json:"can_stop_itself" form:"can_stop_itself"`
SystemPrompt string `json:"system_prompt" form:"system_prompt"`
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
LongTermMemory bool `json:"long_term_memory" form:"long_term_memory"`
SummaryLongTermMemory bool `json:"summary_long_term_memory" form:"summary_long_term_memory"`
ConversationStorageMode string `json:"conversation_storage_mode" form:"conversation_storage_mode"`
ParallelJobs int `json:"parallel_jobs" form:"parallel_jobs"`
StripThinkingTags bool `json:"strip_thinking_tags" form:"strip_thinking_tags"`
EnableEvaluation bool `json:"enable_evaluation" form:"enable_evaluation"`
MaxEvaluationLoops int `json:"max_evaluation_loops" form:"max_evaluation_loops"`
@@ -168,6 +194,13 @@ func NewAgentConfigMeta(
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "plan_reviewer_model",
Label: "Plan Reviewer Model",
Type: "text",
DefaultValue: "",
Tags: config.Tags{Section: "ModelSettings"},
},
{
Name: "api_url",
Label: "API URL",
@@ -212,6 +245,31 @@ func NewAgentConfigMeta(
Step: 1,
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "enable_kb_compaction",
Label: "Enable KB Compaction",
Type: "checkbox",
DefaultValue: false,
HelpText: "Periodically group collection entries by date (daily/weekly/monthly), optionally summarize or concatenate, then store and remove originals",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_compaction_interval",
Label: "KB Compaction Interval",
Type: "text",
DefaultValue: "daily",
Placeholder: "daily, weekly, monthly",
HelpText: "Compaction window: daily, weekly, or monthly",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_compaction_summarize",
Label: "KB Compaction Summarize",
Type: "checkbox",
DefaultValue: true,
HelpText: "When enabled, summarize grouped content via LLM; when disabled, store concatenated content only (no LLM call)",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "long_term_memory",
Label: "Long Term Memory",
@@ -226,6 +284,35 @@ func NewAgentConfigMeta(
DefaultValue: false,
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_auto_search",
Label: "KB Auto Search",
Type: "checkbox",
DefaultValue: true,
HelpText: "Automatically search knowledge base when a user message is received",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "kb_as_tools",
Label: "KB As Tools",
Type: "checkbox",
DefaultValue: false,
HelpText: "Inject knowledge base search and add actions as tools, allowing the agent to access its memory without manual configuration",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "conversation_storage_mode",
Label: "Conversation Storage Mode",
Type: "select",
DefaultValue: "user_only",
Options: []config.FieldOption{
{Value: "user_only", Label: "User Messages Only"},
{Value: "user_and_assistant", Label: "User and Assistant Messages"},
{Value: "whole_conversation", Label: "Whole Conversation as Block"},
},
HelpText: "Controls what gets stored in the knowledge base: only user messages, user and assistant messages separately, or the entire conversation as a single block",
Tags: config.Tags{Section: "MemorySettings"},
},
{
Name: "system_prompt",
Label: "System Prompt",
@@ -283,6 +370,15 @@ func NewAgentConfigMeta(
HelpText: "Duration for scheduling periodic agent runs",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "scheduler_poll_interval",
Label: "Scheduler Poll Interval",
Type: "text",
DefaultValue: "30s",
Placeholder: "30s",
HelpText: "Duration for polling the scheduler for planned tasks",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "enable_reasoning",
Label: "Enable Reasoning",
@@ -291,6 +387,14 @@ func NewAgentConfigMeta(
HelpText: "Enable agent to explain its reasoning process",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "enable_guided_tools",
Label: "Enable Guided Tools",
Type: "checkbox",
DefaultValue: false,
HelpText: "Filter tools through guidance using their descriptions; creates virtual guidelines when none exist",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "parallel_jobs",
Label: "Parallel Jobs",
@@ -301,13 +405,21 @@ func NewAgentConfigMeta(
HelpText: "Number of concurrent tasks that can run in parallel",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "disable_sink_state",
Label: "Disable Sink State",
Type: "checkbox",
DefaultValue: false,
HelpText: "Disable the sink state of the agent",
Tags: config.Tags{Section: "AdvancedSettings"},
},
{
Name: "mcp_stdio_servers",
Label: "MCP STDIO Servers",
Type: "textarea",
DefaultValue: "",
HelpText: "JSON configuration for MCP STDIO servers",
Tags: config.Tags{Section: "AdvancedSettings"},
Tags: config.Tags{Section: "MCP"},
},
{
Name: "mcp_prepare_script",
@@ -315,7 +427,7 @@ func NewAgentConfigMeta(
Type: "textarea",
DefaultValue: "",
HelpText: "Script to prepare for running MCP servers",
Tags: config.Tags{Section: "AdvancedSettings"},
Tags: config.Tags{Section: "MCP"},
},
{
Name: "strip_thinking_tags",
@@ -386,6 +498,9 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
aux := &struct {
*Alias
MCPSTDIOServersConfig interface{} `json:"mcp_stdio_servers"`
MaxEvaluationLoops interface{} `json:"max_evaluation_loops"`
ParallelJobs interface{} `json:"parallel_jobs"`
KnowledgeBaseResults interface{} `json:"kb_results"`
}{
Alias: (*Alias)(a),
}
@@ -394,6 +509,11 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
return err
}
// Parse integer fields that may come as strings
a.MaxEvaluationLoops = parseIntField(aux.MaxEvaluationLoops)
a.ParallelJobs = parseIntField(aux.ParallelJobs)
a.KnowledgeBaseResults = parseIntField(aux.KnowledgeBaseResults)
// Handle MCP STDIO servers configuration
if aux.MCPSTDIOServersConfig != nil {
switch v := aux.MCPSTDIOServersConfig.(type) {
@@ -412,7 +532,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = make([]agent.MCPSTDIOServer, 0, len(mcpConfig.MCPServers))
for _, server := range mcpConfig.MCPServers {
for name, server := range mcpConfig.MCPServers {
// Convert env map to slice of "KEY=VALUE" strings
envSlice := make([]string, 0, len(server.Env))
for k, v := range server.Env {
@@ -420,6 +540,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: server.Command,
Args: server.Args,
Env: envSlice,
@@ -434,6 +555,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
return fmt.Errorf("invalid server configuration format")
}
name, _ := serverMap["name"].(string)
cmd, _ := serverMap["cmd"].(string)
args := make([]string, 0)
if argsInterface, ok := serverMap["args"].([]interface{}); ok {
@@ -454,6 +576,7 @@ func (a *AgentConfig) UnmarshalJSON(data []byte) error {
}
a.MCPSTDIOServers = append(a.MCPSTDIOServers, agent.MCPSTDIOServer{
Name: name,
Cmd: cmd,
Args: args,
Env: env,
@@ -502,7 +625,11 @@ func (a *AgentConfig) MarshalJSON() ([]byte, error) {
}
}
mcpConfig.MCPServers[fmt.Sprintf("server%d", i)] = struct {
key := server.Name
if key == "" {
key = fmt.Sprintf("server%d", i)
}
mcpConfig.MCPServers[key] = struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
+54 -104
View File
@@ -2,7 +2,6 @@ package state
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
@@ -15,11 +14,8 @@ import (
. "github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/sse"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/llm"
"github.com/mudler/LocalAGI/pkg/localrag"
"github.com/mudler/LocalAGI/pkg/utils"
"github.com/sashabaranov/go-openai"
"github.com/sashabaranov/go-openai/jsonschema"
"github.com/mudler/xlog"
)
@@ -34,7 +30,7 @@ type AgentPool struct {
agentStatus map[string]*Status
apiURL, defaultModel, defaultMultimodalModel, defaultTTSModel string
defaultTranscriptionModel, defaultTranscriptionLanguage string
imageModel, localRAGAPI, localRAGKey, apiKey string
localRAGAPI, localRAGKey, apiKey string
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action
connectors func(*AgentConfig) []Connector
dynamicPrompt func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []DynamicPrompt
@@ -74,7 +70,7 @@ func loadPoolFromFile(path string) (*AgentPoolData, error) {
}
func NewAgentPool(
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, imageModel, apiURL, apiKey, directory string,
defaultModel, defaultMultimodalModel, defaultTranscriptionModel, defaultTranscriptionLanguage, defaultTTSModel, apiURL, apiKey, directory string,
LocalRAGAPI string,
availableActions func(*AgentConfig) func(ctx context.Context, pool *AgentPool) []types.Action,
connectors func(*AgentConfig) []Connector,
@@ -92,7 +88,6 @@ func NewAgentPool(
if withLogs {
conversationPath = filepath.Join(directory, "conversations")
}
if _, err := os.Stat(poolfile); err != nil {
// file does not exist, create a new pool
return &AgentPool{
@@ -104,7 +99,6 @@ func NewAgentPool(
defaultTranscriptionModel: defaultTranscriptionModel,
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
defaultTTSModel: defaultTTSModel,
imageModel: imageModel,
localRAGAPI: LocalRAGAPI,
apiKey: apiKey,
agents: make(map[string]*Agent),
@@ -133,7 +127,6 @@ func NewAgentPool(
defaultTranscriptionModel: defaultTranscriptionModel,
defaultTranscriptionLanguage: defaultTranscriptionLanguage,
defaultTTSModel: defaultTTSModel,
imageModel: imageModel,
apiKey: apiKey,
agents: make(map[string]*Agent),
managers: make(map[string]sse.Manager),
@@ -170,14 +163,7 @@ func (a *AgentPool) CreateAgent(name string, agentConfig *AgentConfig) error {
return err
}
go func(ac AgentConfig) {
// Create the agent avatar
if err := createAgentAvatar(a.apiURL, a.apiKey, a.defaultModel, a.imageModel, a.pooldir, ac); err != nil {
xlog.Error("Failed to create agent avatar", "error", err)
}
}(a.pool[name])
return a.startAgentWithConfig(name, agentConfig, nil)
return a.startAgentWithConfig(name, a.pooldir, agentConfig, nil)
}
func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
@@ -213,7 +199,7 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
return err
}
if err := a.startAgentWithConfig(name, agentConfig, obs); err != nil {
if err := a.startAgentWithConfig(name, a.pooldir, agentConfig, obs); err != nil {
if obs != nil {
o.Completion = &types.Completion{Error: err.Error()}
obs.Update(*o)
@@ -229,84 +215,6 @@ func (a *AgentPool) RecreateAgent(name string, agentConfig *AgentConfig) error {
return nil
}
func createAgentAvatar(APIURL, APIKey, model, imageModel, avatarDir string, agent AgentConfig) error {
client := llm.NewClient(APIKey, APIURL+"/v1", "10m")
if imageModel == "" {
return fmt.Errorf("image model not set")
}
if model == "" {
return fmt.Errorf("default model not set")
}
imagePath := filepath.Join(avatarDir, "avatars", fmt.Sprintf("%s.png", agent.Name))
if _, err := os.Stat(imagePath); err == nil {
// Image already exists
xlog.Debug("Avatar already exists", "path", imagePath)
return nil
}
var results struct {
ImagePrompt string `json:"image_prompt"`
}
err := llm.GenerateTypedJSONWithGuidance(
context.Background(),
llm.NewClient(APIKey, APIURL, "10m"),
"Generate a prompt that I can use to create a random avatar for the bot '"+agent.Name+"', the description of the bot is: "+agent.Description,
model,
jsonschema.Definition{
Type: jsonschema.Object,
Properties: map[string]jsonschema.Definition{
"image_prompt": {
Type: jsonschema.String,
Description: "The prompt to generate the image",
},
},
Required: []string{"image_prompt"},
}, &results)
if err != nil {
return fmt.Errorf("failed to generate image prompt: %w", err)
}
if results.ImagePrompt == "" {
xlog.Error("Failed to generate image prompt")
return fmt.Errorf("failed to generate image prompt")
}
req := openai.ImageRequest{
Prompt: results.ImagePrompt,
Model: imageModel,
Size: openai.CreateImageSize256x256,
ResponseFormat: openai.CreateImageResponseFormatB64JSON,
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := client.CreateImage(ctx, req)
if err != nil {
return fmt.Errorf("failed to generate image: %w", err)
}
if len(resp.Data) == 0 {
return fmt.Errorf("failed to generate image")
}
imageJson := resp.Data[0].B64JSON
os.MkdirAll(filepath.Join(avatarDir, "avatars"), 0755)
// Save the image to the agent directory
imageData, err := base64.StdEncoding.DecodeString(imageJson)
if err != nil {
return err
}
return os.WriteFile(imagePath, imageData, 0644)
}
func (a *AgentPool) List() []string {
a.Lock()
defer a.Unlock()
@@ -328,7 +236,7 @@ func (a *AgentPool) GetStatusHistory(name string) *Status {
return a.agentStatus[name]
}
func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs Observer) error {
func (a *AgentPool) startAgentWithConfig(name, pooldir string, config *AgentConfig, obs Observer) error {
var manager sse.Manager
if m, ok := a.managers[name]; ok {
manager = m
@@ -367,6 +275,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
config.PeriodicRuns = "10m"
}
if config.SchedulerPollInterval == "" {
config.SchedulerPollInterval = "30s"
}
// XXX: Why do we update the pool config from an Agent's config?
if config.APIURL != "" {
a.apiURL = config.APIURL
@@ -429,6 +341,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
opts := []Option{
WithSchedulerStorePath(filepath.Join(pooldir, fmt.Sprintf("scheduler-%s.json", name))),
WithModel(model),
WithLLMAPIURL(a.apiURL),
WithContext(ctx),
@@ -437,6 +350,7 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
WithTranscriptionLanguage(transcriptionLanguage),
WithTTSModel(ttsModel),
WithPeriodicRuns(config.PeriodicRuns),
WithSchedulerPollInterval(config.SchedulerPollInterval),
WithPermanentGoal(config.PermanentGoal),
WithMCPSTDIOServers(config.MCPSTDIOServers...),
WithPrompts(promptBlocks...),
@@ -453,7 +367,6 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
WithCharacterFile(characterFile),
WithLLMAPIKey(a.apiKey),
WithTimeout(a.timeout),
WithRAGDB(localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)),
WithAgentReasoningCallback(func(state types.ActionCurrentState) bool {
xlog.Info(
"Agent is thinking",
@@ -534,6 +447,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
opts = append(opts, EnableSummaryMemory)
}
if config.ConversationStorageMode != "" {
opts = append(opts, WithConversationStorageMode(ConversationStorageMode(config.ConversationStorageMode)))
}
if config.CanStopItself {
opts = append(opts, CanStopItself)
}
@@ -542,6 +459,14 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
opts = append(opts, EnablePlanning)
}
if config.PlanReviewerModel != "" {
opts = append(opts, WithPlanReviewerLLM(config.PlanReviewerModel))
}
if config.DisableSinkState {
opts = append(opts, DisableSinkState)
}
if config.InitiateConversations {
opts = append(opts, EnableInitiateConversations)
}
@@ -554,14 +479,38 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
}
var ragClient *localrag.WrappedClient
if config.EnableKnowledgeBase {
opts = append(opts, EnableKnowledgeBase)
ragClient = localrag.NewWrappedClient(a.localRAGAPI, a.localRAGKey, name)
opts = append(opts, WithRAGDB(ragClient), EnableKnowledgeBase)
// Set KB auto search option (defaults to true for backward compatibility)
// For backward compatibility: if both new KB fields are false (zero values),
// assume this is an old config and default KBAutoSearch to true
kbAutoSearch := config.KBAutoSearch
if !config.KBAutoSearch && !config.KBAsTools {
// Both new fields are false, likely an old config - default to true for backward compatibility
kbAutoSearch = true
}
opts = append(opts, WithKBAutoSearch(kbAutoSearch))
// Inject KB wrapper actions if enabled
if config.KBAsTools && ragClient != nil {
kbResults := config.KnowledgeBaseResults
if kbResults <= 0 {
kbResults = 5 // Default
}
searchAction, addAction := NewKBWrapperActions(ragClient, kbResults)
opts = append(opts, WithActions(searchAction, addAction))
}
}
if config.EnableReasoning {
opts = append(opts, EnableForceReasoning)
}
if config.EnableGuidedTools {
opts = append(opts, EnableGuidedTools)
}
if config.StripThinkingTags {
opts = append(opts, EnableStripThinkingTags)
}
@@ -598,6 +547,10 @@ func (a *AgentPool) startAgentWithConfig(name string, config *AgentConfig, obs O
}
}()
if config.EnableKnowledgeBase && config.EnableKBCompaction && ragClient != nil {
go runCompactionTicker(ctx, ragClient, config, a.apiURL, a.apiKey, model)
}
xlog.Info("Starting connectors", "name", name, "config", config)
for _, c := range connectors {
@@ -626,7 +579,7 @@ func (a *AgentPool) StartAll() error {
if a.agents[name] != nil { // Agent already started
continue
}
if err := a.startAgentWithConfig(name, &config, nil); err != nil {
if err := a.startAgentWithConfig(name, a.pooldir, &config, nil); err != nil {
xlog.Error("Failed to start agent", "name", name, "error", err)
}
}
@@ -664,7 +617,7 @@ func (a *AgentPool) Start(name string) error {
return nil
}
if config, ok := a.pool[name]; ok {
return a.startAgentWithConfig(name, &config, nil)
return a.startAgentWithConfig(name, a.pooldir, &config, nil)
}
return fmt.Errorf("agent %s not found", name)
@@ -690,9 +643,6 @@ func (a *AgentPool) Remove(name string) error {
delete(a.agents, name)
delete(a.pool, name)
// remove avatar
os.Remove(filepath.Join(a.pooldir, "avatars", fmt.Sprintf("%s.png", name)))
if err := a.save(); err != nil {
return err
}
+6 -5
View File
@@ -102,16 +102,17 @@ func (c *cogitoWrapper) Tool() openai.Tool {
}
}
func (c *cogitoWrapper) Run(args map[string]any) (string, error) {
func (c *cogitoWrapper) Execute(args map[string]any) (string, any, error) {
ctx := c.ctx
if ctx == nil {
ctx = context.Background()
}
result, err := c.action.Run(ctx, c.sharedState, ActionParams(args))
if err != nil {
return "", err
return "", nil, err
}
return result.Result, nil
return result.Result, result, nil
}
// Actions is something the agent can do
@@ -187,8 +188,8 @@ func (a Actions) ToTools() []openai.Tool {
return tools
}
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.Tool {
tools := []cogito.Tool{}
func (a Actions) ToCogitoTools(ctx context.Context, sharedState *AgentSharedState) []cogito.ToolDefinitionInterface {
tools := []cogito.ToolDefinitionInterface{}
for _, action := range a {
tools = append(tools, &cogitoWrapper{action: action, ctx: ctx, sharedState: sharedState})
}
+27
View File
@@ -0,0 +1,27 @@
package types
import (
"github.com/sashabaranov/go-openai"
)
// ConversationMessage represents a message with associated metadata
// Used when the agent initiates new conversations to preserve context
// such as generated images, files, or URLs
type ConversationMessage struct {
Message openai.ChatCompletionMessage
Metadata map[string]interface{}
}
// NewConversationMessage creates a new ConversationMessage with the given message
func NewConversationMessage(msg openai.ChatCompletionMessage) *ConversationMessage {
return &ConversationMessage{
Message: msg,
Metadata: make(map[string]interface{}),
}
}
// WithMetadata adds metadata to the conversation message
func (c *ConversationMessage) WithMetadata(metadata map[string]interface{}) *ConversationMessage {
c.Metadata = metadata
return c
}
+1 -1
View File
@@ -77,7 +77,7 @@ func WithResultCallback(f func(ActionState)) JobOption {
}
}
func WithMetadata(metadata map[string]interface{}) JobOption {
func WithMetadata(metadata map[string]any) JobOption {
return func(j *Job) {
j.Metadata = metadata
}
+8 -3
View File
@@ -1,6 +1,7 @@
package types
import (
"context"
"sync"
"github.com/mudler/cogito"
@@ -61,9 +62,13 @@ func (j *JobResult) SetResponse(response string) {
}
// WaitResult waits for the result of a job
func (j *JobResult) WaitResult() *JobResult {
<-j.ready
func (j *JobResult) WaitResult(ctx context.Context) (*JobResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-j.ready:
}
j.Lock()
defer j.Unlock()
return j
return j, nil
}
+27 -8
View File
@@ -5,8 +5,19 @@ import (
"time"
"github.com/mudler/LocalAGI/core/conversations"
"github.com/mudler/LocalAGI/core/scheduler"
)
// Forward declaration to avoid circular import
type TaskScheduler interface {
CreateTask(task *scheduler.Task) error
GetAllTasks() ([]*scheduler.Task, error)
GetTask(id string) (*scheduler.Task, error)
DeleteTask(id string) error
PauseTask(id string) error
ResumeTask(id string) error
}
// State is the structure
// that is used to keep track of the current state
// and the Agent's short memory that it can update
@@ -29,17 +40,26 @@ const (
DefaultLastMessageDuration = 5 * time.Minute
)
type ReminderActionResponse struct {
Message string `json:"message"`
CronExpr string `json:"cron_expr"` // Cron expression for scheduling
LastRun time.Time `json:"last_run"` // Last time this reminder was triggered
NextRun time.Time `json:"next_run"` // Next scheduled run time
IsRecurring bool `json:"is_recurring"` // Whether this is a recurring reminder
// RecurringReminderParams are the parameters the LLM provides for set_recurring_reminder.
type RecurringReminderParams struct {
Message string `json:"message"`
CronExpr string `json:"cron_expr"`
}
// OneTimeReminderParams are the parameters the LLM provides for set_onetime_reminder.
type OneTimeReminderParams struct {
Message string `json:"message"`
Delay string `json:"delay"` // Go duration format with day support: "30m", "2h", "1d", "1d12h"
}
// ReminderActionResponse is kept for backward compatibility.
// Deprecated: use RecurringReminderParams or OneTimeReminderParams.
type ReminderActionResponse = RecurringReminderParams
type AgentSharedState struct {
ConversationTracker *conversations.ConversationTracker[string] `json:"conversation_tracker"`
Reminders []ReminderActionResponse `json:"reminders"`
Scheduler TaskScheduler `json:"-"` // Not serialized, set at runtime
AgentName string `json:"agent_name"`
}
func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
@@ -48,7 +68,6 @@ func NewAgentSharedState(lastMessageDuration time.Duration) *AgentSharedState {
}
return &AgentSharedState{
ConversationTracker: conversations.NewConversationTracker[string](lastMessageDuration),
Reminders: make([]ReminderActionResponse, 0),
}
}
+5
View File
@@ -16,6 +16,11 @@ services:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
+5
View File
@@ -17,6 +17,11 @@ services:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
+5
View File
@@ -22,6 +22,11 @@ services:
file: docker-compose.yaml
service: dind
localrecall-postgres:
extends:
file: docker-compose.yaml
service: localrecall-postgres
localrecall:
extends:
file: docker-compose.yaml
+43 -11
View File
@@ -8,8 +8,7 @@ services:
image: localai/localai:master
command:
- ${MODEL_NAME:-gemma-3-4b-it-qat}
- ${MULTIMODAL_MODEL:-moondream2-20250414}
- ${IMAGE_MODEL:-sd-1.5-ggml}
- ${MULTIMODAL_MODEL:-gemma-3-4b-it-qat}
- granite-embedding-107m-multilingual
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/readyz"]
@@ -22,23 +21,46 @@ services:
- DEBUG=true
#- LOCALAI_API_KEY=sk-1234567890
volumes:
- ./volumes/models:/models
- ./volumes/backends:/backends
- ./volumes/images:/tmp/generated/images
- models:/models
- backends:/backends
- images:/tmp/generated/images
localrecall-postgres:
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.2}-postgresql
environment:
- POSTGRES_DB=localrecall
- POSTGRES_USER=localrecall
- POSTGRES_PASSWORD=localrecall
ports:
- 5432:5432
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U localrecall"]
interval: 10s
timeout: 5s
retries: 5
localrecall:
image: quay.io/mudler/localrecall:main
image: quay.io/mudler/localrecall:${LOCALRECALL_VERSION:-v0.5.4}
depends_on:
localrecall-postgres:
condition: service_healthy
localai:
condition: service_started
ports:
- 8080
environment:
- COLLECTION_DB_PATH=/db
- DATABASE_URL=postgresql://localrecall:localrecall@localrecall-postgres:5432/localrecall?sslmode=disable
- VECTOR_ENGINE=postgres
- EMBEDDING_MODEL=granite-embedding-107m-multilingual
- FILE_ASSETS=/assets
- OPENAI_API_KEY=sk-1234567890
- OPENAI_BASE_URL=http://localai:8080
- HYBRID_SEARCH_BM25_WEIGHT=0.5
- HYBRID_SEARCH_VECTOR_WEIGHT=0.5
volumes:
- ./volumes/localrag/db:/db
- ./volumes/localrag/assets/:/assets
- localrag_assets:/assets
localrecall-healthcheck:
depends_on:
@@ -64,8 +86,11 @@ services:
dind:
image: docker:dind
privileged: true
command: ["dockerd", "-H", "tcp://0.0.0.0:2375", "-H", "unix:///var/run/docker.sock"]
environment:
- DOCKER_TLS_CERTDIR=""
expose:
- 2375
healthcheck:
test: ["CMD", "docker", "info"]
interval: 10s
@@ -89,7 +114,6 @@ services:
environment:
- LOCALAGI_MODEL=${MODEL_NAME:-gemma-3-4b-it-qat}
- LOCALAGI_MULTIMODAL_MODEL=${MULTIMODAL_MODEL:-moondream2-20250414}
- LOCALAGI_IMAGE_MODEL=${IMAGE_MODEL:-sd-1.5-ggml}
- LOCALAGI_LLM_API_URL=http://localai:8080
#- LOCALAGI_LLM_API_KEY=sk-1234567890
- LOCALAGI_LOCALRAG_URL=http://localrecall:8080
@@ -101,4 +125,12 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./volumes/localagi/:/pool
- localagi_pool:/pool
volumes:
postgres_data:
models:
backends:
images:
localrag_assets:
localagi_pool:
+30 -2
View File
@@ -4,16 +4,20 @@ go 1.24.4
require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/blevesearch/bleve/v2 v2.5.7
github.com/bwmarrin/discordgo v0.29.0
github.com/chasefleming/elem-go v0.30.0
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/eritikass/githubmarkdownconvertergo v0.1.10
github.com/go-telegram/bot v1.17.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/go-github/v69 v69.2.0
github.com/google/uuid v1.6.0
github.com/jung-kurt/gofpdf v1.16.2
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/mudler/cogito v0.4.2
github.com/mudler/cogito v0.8.2-0.20260214201734-da0d4ceb2b44
github.com/mudler/xlog v0.0.1
github.com/onsi/ginkgo/v2 v2.25.3
github.com/onsi/gomega v1.38.2
github.com/philippgille/chromem-go v0.7.0
@@ -35,15 +39,39 @@ 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/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.8 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/mudler/xlog v0.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)
+76 -2
View File
@@ -16,6 +16,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
@@ -27,6 +29,46 @@ 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/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve/v2 v2.5.7 h1:2d9YrL5zrX5EBBW++GOaEKjE+NPWeZGaX77IM26m1Z8=
github.com/blevesearch/bleve/v2 v2.5.7/go.mod h1:yj0NlS7ocGC4VOSAedqDDMktdh2935v2CSWOCDMHdSA=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.8 h1:SlnzF0YGtSlrsOE3oE7EgEX6BIepGpeqxs1IjMbHLQI=
github.com/blevesearch/zapx/v16 v16.2.8/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
@@ -48,8 +90,11 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2 h1:flLYmnQFZNo04x2NPehMbf30m7Pli57xwZ0NFqR/hb0=
github.com/dave-gray101/v2keyauth v0.0.0-20240624150259-c45d584d25e2/go.mod h1:NtWqRzAp/1tw+twkW8uuBenEVVYndEAZACWU3F3xdoQ=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -104,6 +149,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -115,6 +162,7 @@ github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzea
github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 h1:gD0vax+4I+mAj+jEChEf25Ia07Jq7kYOFO5PPhAxFl4=
@@ -126,6 +174,11 @@ 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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
@@ -168,10 +221,19 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mudler/cogito v0.4.2 h1:1PypFvUq3A+iBxMCR0v+sGXlhOsRnvyFZhEE93tsuDU=
github.com/mudler/cogito v0.4.2/go.mod h1:2uhEElCTq8eXSsqJ1JF01oA5h9niXSELVKqCF1PqjEw=
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/xlog v0.0.1 h1:yR3/wszd3ZM6u1n96YITJZ4yUcDgqHSwvQmzUJa+8vg=
github.com/mudler/xlog v0.0.1/go.mod h1:39f5vcd05Qd6GWKM8IjyHNQ7AmOx3ZM0YfhfIGhC18U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@@ -186,6 +248,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY=
github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo=
github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
@@ -207,6 +271,7 @@ github.com/rogpeppe/go-internal v1.13.2-0.20241226121412-a5dc8ff20d0a/go.mod h1:
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM=
@@ -230,7 +295,9 @@ github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02n
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.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=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
@@ -270,6 +337,8 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
go.mau.fi/util v0.3.0 h1:Lt3lbRXP6ZBqTINK0EieRWor3zEwwwrDT14Z5N8RUCs=
go.mau.fi/util v0.3.0/go.mod h1:9dGsBCCbZJstx16YgnVMVi3O2bOizELoKpugLD4FoGs=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
@@ -299,6 +368,7 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -325,6 +395,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -386,6 +458,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g=
+3 -8
View File
@@ -23,9 +23,7 @@ var stateDir = os.Getenv("LOCALAGI_STATE_DIR")
var localRAG = os.Getenv("LOCALAGI_LOCALRAG_URL")
var withLogs = os.Getenv("LOCALAGI_ENABLE_CONVERSATIONS_LOGGING") == "true"
var apiKeysEnv = os.Getenv("LOCALAGI_API_KEYS")
var imageModel = os.Getenv("LOCALAGI_IMAGE_MODEL")
var conversationDuration = os.Getenv("LOCALAGI_CONVERSATION_DURATION")
var localOperatorBaseURL = os.Getenv("LOCALOPERATOR_BASE_URL")
var customActionsDir = os.Getenv("LOCALAGI_CUSTOM_ACTIONS_DIR")
var sshBoxURL = os.Getenv("LOCALAGI_SSHBOX_URL")
@@ -65,17 +63,14 @@ func main() {
transcriptionModel,
transcriptionLanguage,
ttsModel,
imageModel,
apiURL,
apiKey,
stateDir,
localRAG,
services.Actions(map[string]string{
services.ActionConfigBrowserAgentRunner: localOperatorBaseURL,
services.ActionConfigDeepResearchRunner: localOperatorBaseURL,
services.ActionConfigSSHBoxURL: sshBoxURL,
services.ConfigStateDir: stateDir,
services.CustomActionsDir: customActionsDir,
services.ActionConfigSSHBoxURL: sshBoxURL,
services.ConfigStateDir: stateDir,
services.CustomActionsDir: customActionsDir,
}),
services.Connectors,
services.DynamicPrompts(map[string]string{
-149
View File
@@ -1,149 +0,0 @@
package localoperator
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type Client struct {
baseURL string
httpClient *http.Client
}
func NewClient(baseURL string, timeout ...time.Duration) *Client {
defaultTimeout := 30 * time.Second
if len(timeout) > 0 {
defaultTimeout = timeout[0]
}
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
}
}
type AgentRequest struct {
Goal string `json:"goal"`
MaxAttempts int `json:"max_attempts,omitempty"`
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
}
type DesktopAgentRequest struct {
AgentRequest
DesktopURL string `json:"desktop_url"`
}
type DeepResearchRequest struct {
Topic string `json:"topic"`
MaxCycles int `json:"max_cycles,omitempty"`
MaxNoActionAttempts int `json:"max_no_action_attempts,omitempty"`
MaxResults int `json:"max_results,omitempty"`
}
// Response types
type StateDescription struct {
CurrentURL string `json:"current_url"`
PageTitle string `json:"page_title"`
PageContentDescription string `json:"page_content_description"`
Screenshot string `json:"screenshot"`
ScreenshotMimeType string `json:"screenshot_mime_type"`
}
type StateHistory struct {
States []StateDescription `json:"states"`
}
type DesktopStateDescription struct {
ScreenContent string `json:"screen_content"`
ScreenshotPath string `json:"screenshot_path"`
}
type DesktopStateHistory struct {
States []DesktopStateDescription `json:"states"`
}
type SearchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
}
type ResearchResult struct {
Topic string `json:"topic"`
Summary string `json:"summary"`
Sources []SearchResult `json:"sources"`
KnowledgeGaps []string `json:"knowledge_gaps"`
SearchQueries []string `json:"search_queries"`
ResearchCycles int `json:"research_cycles"`
CompletionTime time.Duration `json:"completion_time"`
}
func (c *Client) RunBrowserAgent(req AgentRequest) (*StateHistory, error) {
return post[*StateHistory](c.httpClient, c.baseURL+"/api/browser/run", req)
}
func (c *Client) RunDesktopAgent(req DesktopAgentRequest) (*DesktopStateHistory, error) {
return post[*DesktopStateHistory](c.httpClient, c.baseURL+"/api/desktop/run", req)
}
func (c *Client) RunDeepResearch(req DeepResearchRequest) (*ResearchResult, error) {
return post[*ResearchResult](c.httpClient, c.baseURL+"/api/deep-research/run", req)
}
func (c *Client) Readyz() (string, error) {
return c.get("/readyz")
}
func (c *Client) Healthz() (string, error) {
return c.get("/healthz")
}
func (c *Client) get(path string) (string, error) {
resp, err := c.httpClient.Get(c.baseURL + path)
if err != nil {
return "", fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
return resp.Status, nil
}
func post[T any](client *http.Client, url string, body interface{}) (T, error) {
var result T
jsonBody, err := json.Marshal(body)
if err != nil {
return result, fmt.Errorf("failed to marshal request body: %w", err)
}
fmt.Println("Sending request", "url", url, "body", string(jsonBody))
resp, err := client.Post(url, "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
return result, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()
fmt.Println("Response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return result, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return result, fmt.Errorf("failed to decode response: %w", err)
}
return result, nil
}
+175 -48
View File
@@ -11,6 +11,7 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -39,6 +40,11 @@ func NewWrappedClient(baseURL, apiKey, c string) *WrappedClient {
return wc
}
// Collection returns the collection name for this client.
func (c *WrappedClient) Collection() string {
return c.collection
}
func (c *WrappedClient) Count() int {
entries, err := c.ListEntries(c.collection)
if err != nil {
@@ -90,6 +96,37 @@ func (c *WrappedClient) Store(s string) error {
return c.Client.Store(c.collection, f)
}
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
func (c *WrappedClient) GetEntryContent(entry string) (content string, chunkCount int, err error) {
return c.Client.GetEntryContent(c.collection, entry)
}
// apiResponse is the standardized LocalRecall API response wrapper (since 3f73ff3a).
type apiResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Error *apiError `json:"error,omitempty"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// parseAPIError reads the response body and returns an error from the API response or a generic message.
func parseAPIError(resp *http.Response, body []byte, fallback string) error {
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err == nil && wrap.Error != nil {
if wrap.Error.Details != "" {
return fmt.Errorf("%s: %s", wrap.Error.Message, wrap.Error.Details)
}
return errors.New(wrap.Error.Message)
}
return fmt.Errorf("%s: %s", fallback, string(body))
}
// Result represents a single result from a query.
type Result struct {
ID string
@@ -103,6 +140,13 @@ type Result struct {
Similarity float32
}
// EntryChunk represents a single chunk (legacy; GetEntryContent now returns full file content).
type EntryChunk struct {
ID string `json:"id"`
Content string `json:"content"`
Metadata map[string]string `json:"metadata"`
}
// Client is a client for the RAG API
type Client struct {
BaseURL string
@@ -153,7 +197,8 @@ func (c *Client) CreateCollection(name string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return errors.New("failed to create collection")
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to create collection")
}
return nil
@@ -176,17 +221,30 @@ func (c *Client) ListCollections() ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list collections")
}
var collections []string
err = json.NewDecoder(resp.Body).Decode(&collections)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return collections, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list collections")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Collections []string `json:"collections"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Collections, nil
}
// ListEntries lists all entries in a collection
@@ -206,17 +264,75 @@ func (c *Client) ListEntries(collection string) ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to list entries")
}
var entries []string
err = json.NewDecoder(resp.Body).Decode(&entries)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return entries, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to list entries")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Entries []string `json:"entries"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Entries, nil
}
// GetEntryContent returns the full file content (no chunk overlap) and the number of chunks for the entry.
func (c *Client) GetEntryContent(collection, entry string) (content string, chunkCount int, err error) {
entryEscaped := url.PathEscape(entry)
reqURL := fmt.Sprintf("%s/api/collections/%s/entries/%s", c.BaseURL, collection, entryEscaped)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return "", 0, err
}
c.addAuthHeader(req)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", 0, err
}
if resp.StatusCode != http.StatusOK {
return "", 0, parseAPIError(resp, body, "failed to get entry content")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return "", 0, errors.New(wrap.Error.Message)
}
return "", 0, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Content string `json:"content"`
ChunkCount int `json:"chunk_count"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return "", 0, err
}
return data.Content, data.ChunkCount, nil
}
// DeleteEntry deletes an entry in a collection
@@ -246,19 +362,30 @@ func (c *Client) DeleteEntry(collection, entry string) ([]string, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyResult := new(bytes.Buffer)
bodyResult.ReadFrom(resp.Body)
return nil, errors.New("failed to delete entry: " + bodyResult.String())
}
var results []string
err = json.NewDecoder(resp.Body).Decode(&results)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return results, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to delete entry")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
RemainingEntries []string `json:"remaining_entries"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.RemainingEntries, nil
}
// Search searches a collection
@@ -289,17 +416,30 @@ func (c *Client) Search(collection, query string, maxResults int) ([]Result, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("failed to search collection")
}
var results []Result
err = json.NewDecoder(resp.Body).Decode(&results)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return results, nil
if resp.StatusCode != http.StatusOK {
return nil, parseAPIError(resp, body, "failed to search collection")
}
var wrap apiResponse
if err := json.Unmarshal(body, &wrap); err != nil || !wrap.Success {
if wrap.Error != nil {
return nil, errors.New(wrap.Error.Message)
}
return nil, fmt.Errorf("invalid response: %w", err)
}
var data struct {
Results []Result `json:"results"`
}
if err := json.Unmarshal(wrap.Data, &data); err != nil {
return nil, err
}
return data.Results, nil
}
// Reset resets a collection
@@ -320,9 +460,8 @@ func (c *Client) Reset(collection string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
return errors.New("failed to reset collection: " + b.String())
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to reset collection")
}
return nil
@@ -371,20 +510,8 @@ func (c *Client) Store(collection, filePath string) error {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b := new(bytes.Buffer)
b.ReadFrom(resp.Body)
type response struct {
Error string `json:"error"`
}
var r response
err = json.Unmarshal(b.Bytes(), &r)
if err == nil {
return errors.New("failed to upload file: " + r.Error)
}
return errors.New("failed to upload file")
body, _ := io.ReadAll(resp.Body)
return parseAPIError(resp, body, "failed to upload file")
}
return nil
+47 -31
View File
@@ -22,8 +22,6 @@ const (
// Actions
ActionSearch = "search"
ActionCustom = "custom"
ActionBrowserAgentRunner = "browser-agent-runner"
ActionDeepResearchRunner = "deep-research-runner"
ActionGithubIssueLabeler = "github-issue-labeler"
ActionGithubIssueOpener = "github-issue-opener"
ActionGithubIssueEditor = "github-issue-editor"
@@ -47,16 +45,21 @@ const (
ActionTwitterPost = "twitter-post"
ActionSendMail = "send-mail"
ActionGenerateImage = "generate_image"
ActionGenerateSong = "generate_song"
ActionGeneratePDF = "generate_pdf"
ActionCounter = "counter"
ActionCallAgents = "call_agents"
ActionShellcommand = "shell-command"
ActionSendTelegramMessage = "send-telegram-message"
ActionSetReminder = "set_reminder"
ActionSetRecurringReminder = "set_recurring_reminder"
ActionSetOneTimeReminder = "set_onetime_reminder"
ActionListReminders = "list_reminders"
ActionRemoveReminder = "remove_reminder"
ActionAddToMemory = "add_to_memory"
ActionListMemory = "list_memory"
ActionRemoveFromMemory = "remove_from_memory"
ActionSearchMemory = "search_memory"
ActionPiKVMPowerControl = "pikvm_power_control"
ActionWebhook = "webhook"
)
@@ -79,8 +82,6 @@ var AvailableActions = []string{
ActionGithubGetAllContent,
ActionGithubRepositorySearchFiles,
ActionGithubRepositoryListFiles,
ActionBrowserAgentRunner,
ActionDeepResearchRunner,
ActionGithubRepositoryCreateOrUpdate,
ActionGithubIssueReader,
ActionGithubIssueCommenter,
@@ -94,6 +95,8 @@ var AvailableActions = []string{
ActionWikipedia,
ActionSendMail,
ActionGenerateImage,
ActionGenerateSong,
ActionGeneratePDF,
ActionTwitterPost,
ActionCounter,
ActionCallAgents,
@@ -105,6 +108,7 @@ var AvailableActions = []string{
ActionAddToMemory,
ActionListMemory,
ActionRemoveFromMemory,
ActionSearchMemory,
ActionPiKVMPowerControl,
ActionWebhook,
}
@@ -115,21 +119,21 @@ var DefaultActions = []config.FieldGroup{
Label: "Search",
Fields: actions.SearchConfigMeta(),
},
{
Name: "browser-agent-runner",
Label: "Browser Agent Runner",
Fields: actions.BrowserAgentRunnerConfigMeta(),
},
{
Name: "deep-research-runner",
Label: "Deep Research Runner",
Fields: actions.DeepResearchRunnerConfigMeta(),
},
{
Name: "generate_image",
Label: "Generate Image",
Fields: actions.GenImageConfigMeta(),
},
{
Name: "generate_song",
Label: "Generate Song",
Fields: actions.GenSongConfigMeta(),
},
{
Name: "generate_pdf",
Label: "Generate PDF",
Fields: actions.GenPDFConfigMeta(),
},
{
Name: "add_to_memory",
Label: "Add to Memory",
@@ -145,6 +149,11 @@ var DefaultActions = []config.FieldGroup{
Label: "Remove from Memory",
Fields: actions.RemoveFromMemoryConfigMeta(),
},
{
Name: "search_memory",
Label: "Search Memory",
Fields: actions.SearchMemoryConfigMeta(),
},
{
Name: "github-issue-labeler",
Label: "GitHub Issue Labeler",
@@ -281,8 +290,13 @@ var DefaultActions = []config.FieldGroup{
Fields: actions.SendTelegramMessageConfigMeta(),
},
{
Name: "set_reminder",
Label: "Set Reminder",
Name: "set_recurring_reminder",
Label: "Set Recurring Reminder",
Fields: []config.Field{},
},
{
Name: "set_onetime_reminder",
Label: "Set One-Time Reminder",
Fields: []config.Field{},
},
{
@@ -308,11 +322,9 @@ var DefaultActions = []config.FieldGroup{
}
const (
ActionConfigBrowserAgentRunner = "browser-agent-runner-base-url"
ActionConfigDeepResearchRunner = "deep-research-runner-base-url"
ActionConfigSSHBoxURL = "sshbox-url"
ConfigStateDir = "state-dir"
CustomActionsDir = "custom-actions-dir"
ActionConfigSSHBoxURL = "sshbox-url"
ConfigStateDir = "state-dir"
CustomActionsDir = "custom-actions-dir"
)
func customActions(customActionsDir string, existingActionConfigs map[string]map[string]string) (allActions []types.Action) {
@@ -400,13 +412,17 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
config = map[string]string{}
}
memoryFilePath := memoryPath(agentName, actionsConfigs)
memoryIdxPath := memoryIndexPath(agentName, actionsConfigs)
switch name {
case ActionCustom:
a, err = action.NewCustom(config, "")
case ActionGenerateImage:
a = actions.NewGenImage(config)
case ActionGenerateSong:
a = actions.NewGenSong(config)
case ActionGeneratePDF:
a = actions.NewGenPDF(config)
case ActionSearch:
a = actions.NewSearch(config)
case ActionGithubIssueLabeler:
@@ -419,10 +435,6 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewGithubIssueCloser(config)
case ActionGithubIssueSearcher:
a = actions.NewGithubIssueSearch(config)
case ActionBrowserAgentRunner:
a = actions.NewBrowserAgentRunner(config, actionsConfigs[ActionConfigBrowserAgentRunner])
case ActionDeepResearchRunner:
a = actions.NewDeepResearchRunner(config, actionsConfigs[ActionConfigDeepResearchRunner])
case ActionGithubIssueReader:
a = actions.NewGithubIssueReader(config)
case ActionGithubPRReader:
@@ -467,18 +479,22 @@ func Action(name, agentName string, config map[string]string, pool *state.AgentP
a = actions.NewShell(config, actionsConfigs[ActionConfigSSHBoxURL])
case ActionSendTelegramMessage:
a = actions.NewSendTelegramMessageRunner(config)
case ActionSetReminder:
a = action.NewReminder()
case ActionSetRecurringReminder:
a = action.NewRecurringReminder()
case ActionSetOneTimeReminder:
a = action.NewOneTimeReminder()
case ActionListReminders:
a = action.NewListReminders()
case ActionRemoveReminder:
a = action.NewRemoveReminder()
case ActionAddToMemory:
a, _, _ = actions.NewMemoryActions(memoryFilePath, config)
a, _, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionListMemory:
_, a, _ = actions.NewMemoryActions(memoryFilePath, config)
_, a, _, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionRemoveFromMemory:
_, _, a = actions.NewMemoryActions(memoryFilePath, config)
_, _, a, _ = actions.NewMemoryActions(memoryIdxPath, config)
case ActionSearchMemory:
_, _, _, a = actions.NewMemoryActions(memoryIdxPath, config)
case ActionPiKVMPowerControl:
a = actions.NewPiKVMAction(config)
default:
-140
View File
@@ -1,140 +0,0 @@
package actions
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
api "github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataBrowserAgentHistory = "browser_agent_history"
)
type BrowserAgentRunner struct {
baseURL, customActionName string
client *api.Client
}
func NewBrowserAgentRunner(config map[string]string, defaultURL string) *BrowserAgentRunner {
if config["baseURL"] == "" {
config["baseURL"] = defaultURL
}
timeout := "15m"
if config["timeout"] != "" {
timeout = config["timeout"]
}
duration, err := time.ParseDuration(timeout)
if err != nil {
// If parsing fails, use default 15 minutes
duration = 15 * time.Minute
}
client := api.NewClient(config["baseURL"], duration)
return &BrowserAgentRunner{
client: client,
baseURL: config["baseURL"],
customActionName: config["customActionName"],
}
}
func (b *BrowserAgentRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := api.AgentRequest{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
}
req := api.AgentRequest{
Goal: result.Goal,
MaxAttempts: result.MaxAttempts,
MaxNoActionAttempts: result.MaxNoActionAttempts,
}
stateHistory, err := b.client.RunBrowserAgent(req)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to run browser agent: %w", err)
}
// Format the state history into a readable string
var historyStr string
// for i, state := range stateHistory.States {
// historyStr += fmt.Sprintf("State %d:\n", i+1)
// historyStr += fmt.Sprintf(" URL: %s\n", state.CurrentURL)
// historyStr += fmt.Sprintf(" Title: %s\n", state.PageTitle)
// historyStr += fmt.Sprintf(" Description: %s\n\n", state.PageContentDescription)
// }
historyStr += fmt.Sprintf(" URL: %s\n", stateHistory.States[len(stateHistory.States)-1].CurrentURL)
historyStr += fmt.Sprintf(" Title: %s\n", stateHistory.States[len(stateHistory.States)-1].PageTitle)
historyStr += fmt.Sprintf(" Description: %s\n\n", stateHistory.States[len(stateHistory.States)-1].PageContentDescription)
return types.ActionResult{
Result: fmt.Sprintf("Browser agent completed successfully. History:\n%s", historyStr),
Metadata: map[string]interface{}{MetadataBrowserAgentHistory: stateHistory},
}, nil
}
func (b *BrowserAgentRunner) Definition() types.ActionDefinition {
actionName := "run_browser_agent"
if b.customActionName != "" {
actionName = b.customActionName
}
description := "Run a browser agent to achieve a specific goal, for example: 'Go to https://www.google.com and search for 'LocalAI', and tell me what's on the first page'"
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"goal": {
Type: jsonschema.String,
Description: "The goal for the browser agent to achieve",
},
"max_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts the agent can make (optional)",
},
"max_no_action_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts without taking an action (optional)",
},
},
Required: []string{"goal"},
}
}
func (a *BrowserAgentRunner) Plannable() bool {
return true
}
// BrowserAgentRunnerConfigMeta returns the metadata for Browser Agent Runner action configuration fields
func BrowserAgentRunnerConfigMeta() []config.Field {
return []config.Field{
{
Name: "baseURL",
Label: "Base URL",
Type: config.FieldTypeText,
Required: false,
HelpText: "Base URL of the LocalOperator API",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
{
Name: "timeout",
Label: "Client Timeout",
Type: config.FieldTypeText,
Required: false,
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
},
}
}
-148
View File
@@ -1,148 +0,0 @@
package actions
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
api "github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataDeepResearchResult = "deep_research_result"
)
type DeepResearchRunner struct {
baseURL, customActionName string
client *api.Client
}
func NewDeepResearchRunner(config map[string]string, defaultURL string) *DeepResearchRunner {
if config["baseURL"] == "" {
config["baseURL"] = defaultURL
}
timeout := "15m"
if config["timeout"] != "" {
timeout = config["timeout"]
}
duration, err := time.ParseDuration(timeout)
if err != nil {
// If parsing fails, use default 15 minutes
duration = 15 * time.Minute
}
client := api.NewClient(config["baseURL"], duration)
return &DeepResearchRunner{
client: client,
baseURL: config["baseURL"],
customActionName: config["customActionName"],
}
}
func (d *DeepResearchRunner) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := api.DeepResearchRequest{}
err := params.Unmarshal(&result)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to unmarshal params: %w", err)
}
req := api.DeepResearchRequest{
Topic: result.Topic,
MaxCycles: result.MaxCycles,
MaxNoActionAttempts: result.MaxNoActionAttempts,
MaxResults: result.MaxResults,
}
researchResult, err := d.client.RunDeepResearch(req)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to run deep research: %w", err)
}
// Format the research result into a readable string
var resultStr string
resultStr += "Deep research result\n"
resultStr += fmt.Sprintf("Topic: %s\n", researchResult.Topic)
resultStr += fmt.Sprintf("Summary: %s\n", researchResult.Summary)
resultStr += fmt.Sprintf("Research Cycles: %d\n", researchResult.ResearchCycles)
resultStr += fmt.Sprintf("Completion Time: %s\n\n", researchResult.CompletionTime)
if len(researchResult.Sources) > 0 {
resultStr += "Sources:\n"
for _, source := range researchResult.Sources {
resultStr += fmt.Sprintf("- %s (%s)\n %s\n", source.Title, source.URL, source.Description)
}
}
return types.ActionResult{
Result: fmt.Sprintf("Deep research completed successfully.\n%s", resultStr),
Metadata: map[string]interface{}{MetadataDeepResearchResult: researchResult},
}, nil
}
func (d *DeepResearchRunner) Definition() types.ActionDefinition {
actionName := "run_deep_research"
if d.customActionName != "" {
actionName = d.customActionName
}
description := "Run a deep research on a specific topic, gathering information from multiple sources and providing a comprehensive summary"
return types.ActionDefinition{
Name: types.ActionDefinitionName(actionName),
Description: description,
Properties: map[string]jsonschema.Definition{
"topic": {
Type: jsonschema.String,
Description: "The topic to research",
},
"max_cycles": {
Type: jsonschema.Number,
Description: "Maximum number of research cycles to perform (optional)",
},
"max_no_action_attempts": {
Type: jsonschema.Number,
Description: "Maximum number of attempts without taking an action (optional)",
},
"max_results": {
Type: jsonschema.Number,
Description: "Maximum number of results to collect (optional)",
},
},
Required: []string{"topic"},
}
}
func (d *DeepResearchRunner) Plannable() bool {
return true
}
// DeepResearchRunnerConfigMeta returns the metadata for Deep Research Runner action configuration fields
func DeepResearchRunnerConfigMeta() []config.Field {
return []config.Field{
{
Name: "baseURL",
Label: "Base URL",
Type: config.FieldTypeText,
Required: false,
HelpText: "Base URL of the LocalOperator API",
},
{
Name: "customActionName",
Label: "Custom Action Name",
Type: config.FieldTypeText,
HelpText: "Custom name for this action",
},
{
Name: "timeout",
Label: "Client Timeout",
Type: config.FieldTypeText,
Required: false,
HelpText: "Client timeout duration (e.g. '15m', '1h'). Defaults to '15m' if not specified.",
},
}
}
+156
View File
@@ -0,0 +1,156 @@
package actions
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/jung-kurt/gofpdf"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/xlog"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataPDFs = "pdf_paths"
)
// NewGenPDF creates a new PDF generation action
func NewGenPDF(config map[string]string) *GenPDFAction {
a := &GenPDFAction{
outputDir: config["outputDir"],
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
}
if a.outputDir != "" {
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
xlog.Error("Failed to create output directory", "path", a.outputDir, "error", err)
}
if a.cleanOnStart {
entries, err := os.ReadDir(a.outputDir)
if err == nil {
for _, e := range entries {
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
}
}
}
}
return a
}
type GenPDFAction struct {
outputDir string
cleanOnStart bool
}
func (a *GenPDFAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Title string `json:"title"`
Content string `json:"content"`
Filename string `json:"filename"`
}{}
if err := params.Unmarshal(&result); err != nil {
return types.ActionResult{}, err
}
if result.Content == "" {
return types.ActionResult{}, fmt.Errorf("content is required")
}
if a.outputDir == "" {
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_pdf (configure the action with an output directory)")
}
// Generate filename if not provided
filename := result.Filename
if filename == "" {
filename = fmt.Sprintf("document_%d", time.Now().UnixNano())
}
// Clean filename to prevent path traversal
filename = filepath.Base(filename)
// Ensure filename has .pdf extension
if !strings.HasSuffix(strings.ToLower(filename), ".pdf") {
filename = filename + ".pdf"
}
// Create PDF
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Add title if provided
if result.Title != "" {
pdf.SetFont("Arial", "B", 16)
pdf.MultiCell(0, 10, result.Title, "", "", false)
pdf.Ln(5)
}
// Add content
pdf.SetFont("Arial", "", 12)
pdf.MultiCell(0, 10, result.Content, "", "", false)
// Save PDF
savedPath := filepath.Join(a.outputDir, filename)
if err := pdf.OutputFileAndClose(savedPath); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to save PDF: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("PDF generated and saved to: %s", savedPath),
Metadata: map[string]interface{}{
MetadataPDFs: []string{savedPath},
},
}, nil
}
func (a *GenPDFAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "generate_pdf",
Description: "Generate a PDF document from text content. The PDF is saved locally and can be sent to the user by connectors.",
Properties: map[string]jsonschema.Definition{
"title": {
Type: jsonschema.String,
Description: "Title of the PDF document",
},
"content": {
Type: jsonschema.String,
Description: "Text content to include in the PDF document",
},
"filename": {
Type: jsonschema.String,
Description: "Optional custom filename (extension is optional - .pdf will be automatically added if missing)",
},
},
Required: []string{"content"},
}
}
func (a *GenPDFAction) Plannable() bool {
return true
}
// GenPDFConfigMeta returns the metadata for GenPDF action configuration fields.
func GenPDFConfigMeta() []config.Field {
return []config.Field{
{
Name: "outputDir",
Label: "Output directory",
Type: config.FieldTypeText,
Required: true,
HelpText: "Directory where generated PDF files are saved",
},
{
Name: "cleanOnStart",
Label: "Clean output directory on start",
Type: config.FieldTypeCheckbox,
DefaultValue: false,
HelpText: "If enabled, clear the output directory when the action is loaded",
},
}
}
+164
View File
@@ -0,0 +1,164 @@
package actions_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services/actions"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("GenPDFAction", func() {
var (
tmpDir string
action *actions.GenPDFAction
ctx context.Context
sharedState *types.AgentSharedState
)
BeforeEach(func() {
var err error
tmpDir, err = os.MkdirTemp("", "genpdf_test_*")
Expect(err).ToNot(HaveOccurred())
action = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
})
ctx = context.Background()
sharedState = &types.AgentSharedState{}
})
AfterEach(func() {
os.RemoveAll(tmpDir)
})
It("generates PDF with title and content", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"title": "Test Document",
"content": "This is test content for the PDF.",
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
Expect(result.Metadata).To(HaveKey(actions.MetadataPDFs))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
})
It("requires content parameter", func() {
_, err := action.Run(ctx, sharedState, types.ActionParams{
"title": "Test",
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("content is required"))
})
It("uses custom filename when provided", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "custom_name",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("custom_name.pdf"))
})
It("generates PDF with content only (no title)", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Just some content without a title.",
})
Expect(err).ToNot(HaveOccurred())
Expect(result.Result).To(ContainSubstring("PDF generated and saved to:"))
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(paths).To(HaveLen(1))
Expect(paths[0]).To(BeAnExistingFile())
})
It("automatically adds .pdf extension if missing", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "my_document",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("my_document.pdf"))
})
It("does not double-add .pdf extension", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "document.pdf",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
Expect(filepath.Base(paths[0])).To(Equal("document.pdf"))
})
It("requires outputDir to be configured", func() {
actionNoDir := actions.NewGenPDF(map[string]string{})
_, err := actionNoDir.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("outputDir is required"))
})
It("cleans output directory on start if cleanOnStart is enabled", func() {
// Create a test file in the directory
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
Expect(err).ToNot(HaveOccurred())
Expect(testFile).To(BeAnExistingFile())
// Create a new action with cleanOnStart enabled
_ = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
"cleanOnStart": "true",
})
// The test file should be deleted
Expect(testFile).ToNot(BeAnExistingFile())
})
It("does not clean output directory if cleanOnStart is disabled", func() {
// Create a test file in the directory
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("test"), 0644)
Expect(err).ToNot(HaveOccurred())
Expect(testFile).To(BeAnExistingFile())
// Create a new action with cleanOnStart disabled (default)
_ = actions.NewGenPDF(map[string]string{
"outputDir": tmpDir,
})
// The test file should still exist
Expect(testFile).To(BeAnExistingFile())
})
It("prevents path traversal in filename", func() {
result, err := action.Run(ctx, sharedState, types.ActionParams{
"content": "Test content",
"filename": "../../../etc/passwd",
})
Expect(err).ToNot(HaveOccurred())
paths := result.Metadata[actions.MetadataPDFs].([]string)
// Should only use the base filename, not the path
Expect(filepath.Base(paths[0])).To(Equal("passwd.pdf"))
// Should be in the tmpDir, not in /etc
Expect(filepath.Dir(paths[0])).To(Equal(tmpDir))
})
})
+302
View File
@@ -0,0 +1,302 @@
package actions
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/dhowden/tag"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
const (
MetadataSongs = "songs_paths"
)
// audioExtensionFromContentType returns a file extension for common audio MIME types.
// It strips parameters (e.g. "audio/flac; rate=44100" -> "flac").
func audioExtensionFromContentType(contentType string) string {
mediaType, _, _ := strings.Cut(strings.TrimSpace(contentType), ";")
mediaType = strings.TrimSpace(strings.ToLower(mediaType))
switch mediaType {
case "audio/flac":
return "flac"
case "audio/mpeg", "audio/mp3":
return "mp3"
case "audio/wav", "audio/wave", "audio/x-wav":
return "wav"
case "audio/ogg":
return "ogg"
case "audio/webm":
return "webm"
default:
return ""
}
}
// audioExtensionFromTag uses github.com/dhowden/tag to identify format from audio bytes.
// Identify works on raw audio (e.g. FLAC without vorbis comments) and returns FileType.
func audioExtensionFromTag(data []byte) string {
if len(data) < 11 {
return ""
}
r := bytes.NewReader(data)
_, fileType, err := tag.Identify(r)
if err != nil || fileType == tag.UnknownFileType {
return ""
}
switch fileType {
case tag.FLAC:
return "flac"
case tag.MP3:
return "mp3"
case tag.OGG:
return "ogg"
case tag.M4A:
return "m4a"
case tag.M4B:
return "m4b"
case tag.M4P:
return "m4p"
case tag.ALAC:
return "m4a"
case tag.DSF:
return "dsf"
default:
return ""
}
}
// soundRequest matches LocalAI /sound endpoint (ACE-Step advanced mode) request body.
// See: https://localai.io/features/text-to-audio/
type soundRequest struct {
Model string `json:"model"`
Caption string `json:"caption"`
Lyrics string `json:"lyrics,omitempty"`
BPM *int `json:"bpm,omitempty"`
Keyscale string `json:"keyscale,omitempty"`
Language string `json:"language,omitempty"`
DurationSeconds *float64 `json:"duration_seconds,omitempty"`
}
func NewGenSong(config map[string]string) *GenSongAction {
model := config["model"]
if model == "" {
model = "ace-step-turbo"
}
a := &GenSongAction{
apiURL: strings.TrimSuffix(config["apiURL"], "/"),
apiKey: config["apiKey"],
outputDir: config["outputDir"],
model: model,
cleanOnStart: config["cleanOnStart"] == "true" || config["cleanOnStart"] == "1",
}
if a.outputDir != "" {
if err := os.MkdirAll(a.outputDir, 0755); err != nil {
// log but continue; Run will fail with a clear error when saving
_ = err
}
if a.cleanOnStart {
entries, err := os.ReadDir(a.outputDir)
if err == nil {
for _, e := range entries {
_ = os.Remove(filepath.Join(a.outputDir, e.Name()))
}
}
}
}
return a
}
type GenSongAction struct {
apiURL string
apiKey string
outputDir string
model string
cleanOnStart bool
}
func (a *GenSongAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
result := struct {
Caption string `json:"caption"`
Lyrics string `json:"lyrics"`
BPM *int `json:"bpm"`
Keyscale string `json:"keyscale"`
Language string `json:"language"`
Duration *float64 `json:"duration_seconds"`
Model string `json:"model"`
}{}
if err := params.Unmarshal(&result); err != nil {
return types.ActionResult{}, err
}
if result.Caption == "" {
return types.ActionResult{}, fmt.Errorf("caption is required")
}
if a.outputDir == "" {
return types.ActionResult{}, fmt.Errorf("outputDir is required for generate_song (configure the action with an output directory)")
}
reqBody := soundRequest{
Model: a.model,
Caption: result.Caption,
Lyrics: result.Lyrics,
Keyscale: result.Keyscale,
Language: result.Language,
DurationSeconds: result.Duration,
BPM: result.BPM,
}
body, err := json.Marshal(reqBody)
if err != nil {
return types.ActionResult{}, err
}
url := a.apiURL + "/v1/sound-generation"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return types.ActionResult{}, err
}
req.Header.Set("Content-Type", "application/json")
if a.apiKey != "" {
req.Header.Set("xi-api-key", a.apiKey)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return types.ActionResult{Result: "Failed to generate song: " + err.Error()}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(resp.Body)
return types.ActionResult{}, fmt.Errorf("sound endpoint failed: %s: %s", resp.Status, string(msg))
}
audioBytes, err := io.ReadAll(resp.Body)
if err != nil {
return types.ActionResult{}, err
}
if len(audioBytes) == 0 {
return types.ActionResult{}, fmt.Errorf("no audio data returned")
}
ext := audioExtensionFromContentType(resp.Header.Get("Content-Type"))
if ext == "" {
ext = audioExtensionFromTag(audioBytes)
}
if ext == "" {
ext = "flac" // default when unknown (e.g. ACE-Step)
}
filename := fmt.Sprintf("song_%d.%s", time.Now().UnixNano(), ext)
savedPath := filepath.Join(a.outputDir, filename)
if err := os.WriteFile(savedPath, audioBytes, 0644); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to save song: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("The song was generated and saved to: %s", savedPath),
Metadata: map[string]interface{}{
MetadataSongs: []string{savedPath},
},
}, nil
}
func (a *GenSongAction) Definition() types.ActionDefinition {
return types.ActionDefinition{
Name: "generate_song",
Description: "Generate a song or music track using LocalAI /sound endpoint (ACE-Step advanced mode). Uses caption, optional lyrics, BPM, key scale, language and duration. The file is saved locally and can be sent to the user by connectors.",
Properties: map[string]jsonschema.Definition{
"caption": {
Type: jsonschema.String,
Description: "Description of the song or music to generate (e.g. 'A funky Japanese disco track').",
},
"lyrics": {
Type: jsonschema.String,
Description: "Lyrics or structure (e.g. '[Verse 1]\\n...').",
},
"bpm": {
Type: jsonschema.Integer,
Description: "Beats per minute (e.g. 120). Optional.",
},
"keyscale": {
Type: jsonschema.String,
Description: "Key and scale (e.g. 'Ab major'). Optional.",
},
"language": {
Type: jsonschema.String,
Description: "Language code for vocals (e.g. 'ja', 'en'). Optional.",
},
"duration_seconds": {
Type: jsonschema.Number,
Description: "Duration of the generated audio in seconds (e.g. 225). Optional.",
},
"model": {
Type: jsonschema.String,
Description: "Model name (e.g. ace-step-turbo). Optional; uses action config default if omitted.",
},
},
Required: []string{"caption"},
}
}
func (a *GenSongAction) Plannable() bool {
return true
}
// GenSongConfigMeta returns the metadata for GenSong action configuration fields.
func GenSongConfigMeta() []config.Field {
return []config.Field{
{
Name: "apiURL",
Label: "API URL",
Type: config.FieldTypeText,
Required: true,
DefaultValue: "http://localhost:8080",
HelpText: "LocalAI base URL (e.g. http://localhost:8080) for /sound endpoint",
},
{
Name: "model",
Label: "Model",
Type: config.FieldTypeText,
Required: false,
DefaultValue: "ace-step-turbo",
HelpText: "Default model for sound generation (e.g. ace-step-turbo)",
},
{
Name: "apiKey",
Label: "API Key",
Type: config.FieldTypeText,
Required: false,
HelpText: "Optional API key if the endpoint requires authentication",
},
{
Name: "outputDir",
Label: "Output directory",
Type: config.FieldTypeText,
Required: true,
HelpText: "Directory where generated song files are saved (required for connectors to send files)",
},
{
Name: "cleanOnStart",
Label: "Clean output directory on start",
Type: config.FieldTypeCheckbox,
DefaultValue: false,
HelpText: "If enabled, clear the output directory when the action is loaded",
},
}
}
+284 -103
View File
@@ -2,22 +2,29 @@ package actions
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"io"
"github.com/blevesearch/bleve/v2"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/sashabaranov/go-openai/jsonschema"
)
// Remove global const and mutex, and add them as fields to a struct
// indexCache avoids opening the same Bleve index path multiple times, which would
// deadlock (Bleve uses file locks; a second Open() on the same path blocks).
var (
indexCache = map[string]bleve.Index{}
indexCacheMu sync.Mutex
)
type MemoryActions struct {
filePath string
index bleve.Index
indexPath string
customName string
customDescription string
}
@@ -25,138 +32,269 @@ type MemoryActions struct {
type AddToMemoryAction struct{ *MemoryActions }
type ListMemoryAction struct{ *MemoryActions }
type RemoveFromMemoryAction struct{ *MemoryActions }
type SearchMemoryAction struct{ *MemoryActions }
// NewMemoryActions returns the three actions, using the provided filePath and config
func NewMemoryActions(filePath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction) {
ma := &MemoryActions{filePath: filePath}
// MemoryEntry matches the MCP memory structure (Bleve-backed).
type MemoryEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// NewMemoryActions returns the four memory actions (Add, List, Remove, Search) using a Bleve index at indexPath.
func NewMemoryActions(indexPath string, config map[string]string) (*AddToMemoryAction, *ListMemoryAction, *RemoveFromMemoryAction, *SearchMemoryAction) {
ma := &MemoryActions{indexPath: indexPath}
if config != nil {
ma.customName = config["custom_name"]
ma.customDescription = config["custom_description"]
}
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}
}
type addToMemoryParams struct {
Item string `json:"item"`
}
type removeFromMemoryParams struct {
Index *int `json:"index,omitempty"`
Value string `json:"value,omitempty"`
}
func (m *MemoryActions) readMemory() ([]string, error) {
f, err := os.Open(m.filePath)
idx, err := openOrCreateBleveIndex(indexPath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, err
// Allow lazy init: index will be nil and operations will return this error
ma.index = nil
} else {
ma.index = idx
}
defer f.Close()
var items []string
if err := json.NewDecoder(f).Decode(&items); err != nil {
if err == io.EOF {
return []string{}, nil
}
return nil, err
}
return items, nil
return &AddToMemoryAction{ma}, &ListMemoryAction{ma}, &RemoveFromMemoryAction{ma}, &SearchMemoryAction{ma}
}
func (m *MemoryActions) writeMemory(items []string) error {
f, err := os.Create(m.filePath)
func openOrCreateBleveIndex(indexPath string) (bleve.Index, error) {
indexCacheMu.Lock()
if idx, ok := indexCache[indexPath]; ok {
indexCacheMu.Unlock()
return idx, nil
}
indexCacheMu.Unlock()
var idx bleve.Index
var err error
if _, statErr := os.Stat(indexPath); statErr == nil {
idx, err = bleve.Open(indexPath)
} else {
os.MkdirAll(filepath.Dir(indexPath), 0755)
mapping := bleve.NewIndexMapping()
entryMapping := bleve.NewDocumentMapping()
nameFieldMapping := bleve.NewTextFieldMapping()
nameFieldMapping.Analyzer = "standard"
nameFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("name", nameFieldMapping)
contentFieldMapping := bleve.NewTextFieldMapping()
contentFieldMapping.Analyzer = "standard"
contentFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("content", contentFieldMapping)
dateFieldMapping := bleve.NewDateTimeFieldMapping()
dateFieldMapping.Store = true
entryMapping.AddFieldMappingsAt("created_at", dateFieldMapping)
mapping.AddDocumentMapping("_default", entryMapping)
idx, err = bleve.New(indexPath, mapping)
}
if err != nil {
return nil, err
}
indexCacheMu.Lock()
indexCache[indexPath] = idx
indexCacheMu.Unlock()
return idx, nil
}
func (m *MemoryActions) ensureIndex() error {
if m.index != nil {
return nil
}
idx, err := openOrCreateBleveIndex(m.indexPath)
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(items)
m.index = idx
return nil
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
type addToMemoryParams struct {
Name string `json:"name"`
Content string `json:"content"`
}
type removeFromMemoryParams struct {
ID string `json:"id"`
}
type searchMemoryParams struct {
Query string `json:"query"`
}
func (a *AddToMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req addToMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Item == "" {
return types.ActionResult{}, fmt.Errorf("item cannot be empty")
if req.Name == "" && req.Content == "" {
return types.ActionResult{}, fmt.Errorf("name or content cannot both be empty")
}
items, err := a.readMemory()
if err != nil {
return types.ActionResult{}, err
entry := MemoryEntry{
ID: generateID(),
Name: req.Name,
Content: req.Content,
CreatedAt: time.Now(),
}
items = append(items, req.Item)
if err := a.writeMemory(items); err != nil {
return types.ActionResult{}, err
if err := a.index.Index(entry.ID, entry); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to index memory entry: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("Added item to memory: %s", req.Item),
Metadata: map[string]any{"item": req.Item, "count": len(items)},
Result: fmt.Sprintf("Added memory entry: id=%s name=%q", entry.ID, entry.Name),
Metadata: map[string]any{"id": entry.ID, "name": entry.Name, "content": entry.Content, "created_at": entry.CreatedAt},
}, nil
}
func (a *ListMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
items, err := a.readMemory()
if err != nil {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
query := bleve.NewMatchAllQuery()
searchRequest := bleve.NewSearchRequest(query)
searchRequest.Size = 10000
searchRequest.Fields = []string{"name", "created_at"}
searchRequest.SortBy([]string{"-created_at"})
outputResult := "Number of items in memory: " + strconv.Itoa(len(items)) + "\n"
for i, item := range items {
outputResult += fmt.Sprintf("%d) %s\n", i, item)
searchResult, err := a.index.Search(searchRequest)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
}
type listEntry struct {
Name string
CreatedAt time.Time
}
entries := make([]listEntry, 0, len(searchResult.Hits))
for _, hit := range searchResult.Hits {
e := listEntry{}
if v, ok := hit.Fields["name"].(string); ok {
e.Name = v
}
if v, ok := hit.Fields["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, v); err == nil {
e.CreatedAt = t
}
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
e.CreatedAt = v
}
entries = append(entries, e)
}
outputResult := "Number of items in memory: " + strconv.Itoa(len(entries)) + "\n"
for i, e := range entries {
createdStr := e.CreatedAt.Format(time.RFC3339)
outputResult += fmt.Sprintf("%d) %s (created_at: %s)\n", i, e.Name, createdStr)
}
names := make([]string, len(entries))
for i, e := range entries {
names[i] = e.Name
}
return types.ActionResult{
Result: outputResult,
Metadata: map[string]any{"items": items},
Metadata: map[string]any{"names": names, "entries": entries, "count": len(entries)},
}, nil
}
func (a *RemoveFromMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req removeFromMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
items, err := a.readMemory()
if req.ID == "" {
return types.ActionResult{}, fmt.Errorf("id is required to remove a memory entry")
}
doc, err := a.index.Document(req.ID)
if err != nil {
return types.ActionResult{}, err
return types.ActionResult{}, fmt.Errorf("failed to check document: %w", err)
}
var removed string
if req.Index != nil {
idx := *req.Index
if idx < 0 || idx >= len(items) {
return types.ActionResult{}, fmt.Errorf("index out of range")
}
removed = items[idx]
items = append(items[:idx], items[idx+1:]...)
} else if req.Value != "" {
found := false
for i, v := range items {
if v == req.Value {
removed = v
items = append(items[:i], items[i+1:]...)
found = true
break
}
}
if !found {
return types.ActionResult{}, fmt.Errorf("value not found in memory")
}
} else {
return types.ActionResult{}, fmt.Errorf("must provide index or value to remove")
if doc == nil {
return types.ActionResult{}, fmt.Errorf("memory entry with ID %q not found", req.ID)
}
if err := a.writeMemory(items); err != nil {
return types.ActionResult{}, err
if err := a.index.Delete(req.ID); err != nil {
return types.ActionResult{}, fmt.Errorf("failed to delete memory entry: %w", err)
}
return types.ActionResult{
Result: fmt.Sprintf("Removed item from memory: %s", removed),
Metadata: map[string]any{"removed": removed, "count": len(items)},
Result: fmt.Sprintf("Removed memory entry with ID %q", req.ID),
Metadata: map[string]any{"removed_id": req.ID},
}, nil
}
func (a *SearchMemoryAction) Run(ctx context.Context, sharedState *types.AgentSharedState, params types.ActionParams) (types.ActionResult, error) {
if err := a.ensureIndex(); err != nil {
return types.ActionResult{}, err
}
var req searchMemoryParams
if err := params.Unmarshal(&req); err != nil {
return types.ActionResult{}, fmt.Errorf("invalid parameters: %w", err)
}
if req.Query == "" {
return types.ActionResult{}, fmt.Errorf("query cannot be empty")
}
nameQuery := bleve.NewMatchQuery(req.Query)
nameQuery.SetField("name")
contentQuery := bleve.NewMatchQuery(req.Query)
contentQuery.SetField("content")
disjunctionQuery := bleve.NewDisjunctionQuery(nameQuery, contentQuery)
searchRequest := bleve.NewSearchRequest(disjunctionQuery)
searchRequest.Size = 100
searchRequest.Fields = []string{"name", "content", "created_at"}
searchResult, err := a.index.Search(searchRequest)
if err != nil {
return types.ActionResult{}, fmt.Errorf("failed to search index: %w", err)
}
results := make([]MemoryEntry, 0, len(searchResult.Hits))
for _, hit := range searchResult.Hits {
e := MemoryEntry{ID: hit.ID}
if v, ok := hit.Fields["name"].(string); ok {
e.Name = v
}
if v, ok := hit.Fields["content"].(string); ok {
e.Content = v
}
if v, ok := hit.Fields["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, v); err == nil {
e.CreatedAt = t
}
} else if v, ok := hit.Fields["created_at"].(time.Time); ok {
e.CreatedAt = v
}
results = append(results, e)
}
outputResult := fmt.Sprintf("Query: %q — %d result(s)\n", req.Query, len(results))
for i, e := range results {
outputResult += fmt.Sprintf("%d) [%s] %s — %s\n", i, e.ID, e.Name, e.Content)
}
return types.ActionResult{
Result: outputResult,
Metadata: map[string]any{"query": req.Query, "results": results, "count": len(results)},
}, nil
}
func (a *AddToMemoryAction) Definition() types.ActionDefinition {
name := "add_to_memory"
description := "Add a string item to memory (stored in a JSON file)."
description := "Add a new entry to memory storage (name and/or content). Stored in a Bleve index."
if a.customName != "" {
name = a.customName
}
@@ -167,18 +305,22 @@ func (a *AddToMemoryAction) Definition() types.ActionDefinition {
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"item": {
"name": {
Type: jsonschema.String,
Description: "The string item to add to memory.",
Description: "The name/title of the memory entry.",
},
"content": {
Type: jsonschema.String,
Description: "The content to store in memory.",
},
},
Required: []string{"item"},
Required: []string{},
}
}
func (a *ListMemoryAction) Definition() types.ActionDefinition {
name := "list_memory"
description := "List all items currently stored in memory."
description := "List all memory entry names."
if a.customName != "" {
name = a.customName
}
@@ -195,7 +337,7 @@ func (a *ListMemoryAction) Definition() types.ActionDefinition {
func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
name := "remove_from_memory"
description := "Remove an item from memory by index or value."
description := "Remove a memory entry by ID."
if a.customName != "" {
name = a.customName
}
@@ -206,22 +348,41 @@ func (a *RemoveFromMemoryAction) Definition() types.ActionDefinition {
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"index": {
Type: jsonschema.Integer,
Description: "The index of the item to remove (optional, 0-based)",
},
"value": {
"id": {
Type: jsonschema.String,
Description: "The value of the item to remove (optional)",
Description: "The ID of the memory entry to remove.",
},
},
Required: []string{},
Required: []string{"id"},
}
}
func (a *AddToMemoryAction) Plannable() bool { return true }
func (a *ListMemoryAction) Plannable() bool { return true }
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
func (a *SearchMemoryAction) Definition() types.ActionDefinition {
name := "search_memory"
description := "Search memory entries by name and content using full-text search."
if a.customName != "" {
name = a.customName
}
if a.customDescription != "" {
description = a.customDescription
}
return types.ActionDefinition{
Name: types.ActionDefinitionName(name),
Description: description,
Properties: map[string]jsonschema.Definition{
"query": {
Type: jsonschema.String,
Description: "The search query to find matching memory entries.",
},
},
Required: []string{"query"},
}
}
func (a *AddToMemoryAction) Plannable() bool { return true }
func (a *ListMemoryAction) Plannable() bool { return true }
func (a *RemoveFromMemoryAction) Plannable() bool { return true }
func (a *SearchMemoryAction) Plannable() bool { return true }
// AddToMemoryConfigMeta returns the metadata for AddToMemory action configuration fields
func AddToMemoryConfigMeta() []config.Field {
@@ -238,7 +399,7 @@ func AddToMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'Add a string item to memory (stored in a JSON file).')",
HelpText: "Custom description for the action (optional)",
},
}
}
@@ -258,7 +419,7 @@ func ListMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'List all items currently stored in memory.')",
HelpText: "Custom description for the action (optional)",
},
}
}
@@ -278,7 +439,27 @@ func RemoveFromMemoryConfigMeta() []config.Field {
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional, defaults to 'Remove an item from memory by index or value.')",
HelpText: "Custom description for the action (optional)",
},
}
}
// SearchMemoryConfigMeta returns the metadata for SearchMemory action configuration fields
func SearchMemoryConfigMeta() []config.Field {
return []config.Field{
{
Name: "custom_name",
Label: "Custom Name",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom name for the action (optional, defaults to 'search_memory')",
},
{
Name: "custom_description",
Label: "Custom Description",
Type: config.FieldTypeText,
Required: false,
HelpText: "Custom description for the action (optional)",
},
}
}
+51 -43
View File
@@ -3,6 +3,7 @@ package actions_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/services/actions"
@@ -12,72 +13,79 @@ import (
var _ = Describe("MemoryActions", func() {
var (
tmpFile string
tmpDir string
indexPath string
aAdd *actions.AddToMemoryAction
aList *actions.ListMemoryAction
aRemove *actions.RemoveFromMemoryAction
aSearch *actions.SearchMemoryAction
)
BeforeEach(func() {
f, err := os.CreateTemp("", "memory_test_*.json")
var err error
tmpDir, err = os.MkdirTemp("", "memory_test_*")
Expect(err).ToNot(HaveOccurred())
tmpFile = f.Name()
f.Close()
aAdd, aList, aRemove = actions.NewMemoryActions(tmpFile, map[string]string{})
indexPath = filepath.Join(tmpDir, "memory.bleve")
aAdd, aList, aRemove, aSearch = actions.NewMemoryActions(indexPath, map[string]string{})
})
AfterEach(func() {
os.Remove(tmpFile)
os.RemoveAll(tmpDir)
})
It("adds and lists items", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
It("adds and lists entries by name", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
Expect(err).ToNot(HaveOccurred())
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
Expect(err).ToNot(HaveOccurred())
res, err := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(err).ToNot(HaveOccurred())
Expect(res.Metadata["items"]).To(ContainElements("foo", "bar"))
Expect(res.Metadata["names"]).To(ContainElements("foo", "baz"))
Expect(res.Metadata["count"]).To(Equal(2))
})
It("removes by index", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 0})
It("removes by id", func() {
addRes, _ := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
id, ok := addRes.Metadata["id"].(string)
Expect(ok).To(BeTrue())
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "baz", "content": "qux"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": id})
Expect(err).ToNot(HaveOccurred())
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(res.Metadata["items"]).To(ConsistOf("bar"))
Expect(res.Metadata["names"]).To(ConsistOf("baz"))
})
It("removes by value", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
Expect(err).ToNot(HaveOccurred())
res, _ := aList.Run(context.TODO(), nil, types.ActionParams{})
Expect(res.Metadata["items"]).To(ConsistOf("foo"))
})
It("returns error for out of range index", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"index": 2})
Expect(err).To(HaveOccurred())
})
It("returns error for value not found", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"value": "bar"})
Expect(err).To(HaveOccurred())
})
It("returns error for empty item", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"item": ""})
Expect(err).To(HaveOccurred())
})
It("returns error if neither index nor value provided", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"item": "foo"})
It("returns error for missing id on remove", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "foo", "content": "bar"})
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{})
Expect(err).To(HaveOccurred())
})
It("returns error for unknown id on remove", func() {
_, err := aRemove.Run(context.TODO(), nil, types.ActionParams{"id": "nonexistent"})
Expect(err).To(HaveOccurred())
})
It("returns error for empty name and content on add", func() {
_, err := aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "", "content": ""})
Expect(err).To(HaveOccurred())
})
It("search returns matching entries", func() {
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "meeting", "content": "discussed project X"})
_, _ = aAdd.Run(context.TODO(), nil, types.ActionParams{"name": "lunch", "content": "ate pizza"})
res, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": "project"})
Expect(err).ToNot(HaveOccurred())
Expect(res.Metadata["count"]).To(Equal(1))
results, ok := res.Metadata["results"].([]actions.MemoryEntry)
Expect(ok).To(BeTrue())
Expect(results).To(HaveLen(1))
Expect(results[0].Name).To(Equal("meeting"))
Expect(results[0].Content).To(Equal("discussed project X"))
})
It("search returns error for empty query", func() {
_, err := aSearch.Run(context.TODO(), nil, types.ActionParams{"query": ""})
Expect(err).To(HaveOccurred())
})
})
+22
View File
@@ -3,6 +3,7 @@ package services
import (
"fmt"
"os"
"path/filepath"
"github.com/mudler/xlog"
)
@@ -26,3 +27,24 @@ func memoryPath(agentName string, actionsConfigs map[string]string) string {
return memoryFilePath
}
// memoryIndexPath returns the directory path for the Bleve index (used by memory actions).
func memoryIndexPath(agentName string, actionsConfigs map[string]string) string {
indexPath := "memory.bleve"
if actionsConfigs != nil {
if stateDir, ok := actionsConfigs[ConfigStateDir]; ok && stateDir != "" {
memoryDir := fmt.Sprintf("%s/memory", stateDir)
if err := os.MkdirAll(memoryDir, 0755); err != nil {
xlog.Error("Error creating memory directory", "error", err)
return indexPath
}
indexPath = filepath.Join(memoryDir, agentName+".bleve")
} else {
indexPath = agentName + ".memory.bleve"
}
}
if dir := filepath.Dir(indexPath); dir != "." {
os.MkdirAll(dir, 0755)
}
return indexPath
}
+4 -4
View File
@@ -85,11 +85,11 @@ func (d *Discord) Start(a *agent.Agent) {
if d.defaultChannel != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(discord)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(discord)", "message", ccm.Message.Content)
// Send the message to the default channel
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Content)
_, err := dg.ChannelMessageSend(d.defaultChannel, ccm.Message.Content)
if err != nil {
xlog.Error(fmt.Sprintf("Error sending message: %v", err))
}
@@ -97,7 +97,7 @@ func (d *Discord) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("discord:%s", d.defaultChannel),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+4 -4
View File
@@ -377,14 +377,14 @@ func (e *Email) Start(a *agent.Agent) {
go func() {
if e.defaultEmail != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(email)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(email)", "message", ccm.Message.Content)
// Send the message to the default email
e.sendMail(
e.defaultEmail,
"Message from LocalAGI",
ccm.Content,
ccm.Message.Content,
"",
"",
[]string{e.defaultEmail},
@@ -394,7 +394,7 @@ func (e *Email) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("email:%s", e.defaultEmail),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+4 -4
View File
@@ -73,12 +73,12 @@ func (i *IRC) Start(a *agent.Agent) {
if i.channel != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(irc)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(irc)", "message", ccm.Message.Content)
// Split the response into multiple messages if it's too long
maxLength := 400 // Safe limit for most IRC servers
response := ccm.Content
response := ccm.Message.Content
// Handle multiline responses
lines := strings.Split(response, "\n")
@@ -109,7 +109,7 @@ func (i *IRC) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("irc:%s", i.channel),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+4 -4
View File
@@ -225,16 +225,16 @@ func (m *Matrix) Start(a *agent.Agent) {
if m.roomID != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(matrix)", "message", ccm.Content)
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(matrix)", "message", ccm.Message.Content)
_, err := m.client.SendText(context.Background(), id.RoomID(m.roomID), ccm.Message.Content)
if err != nil {
xlog.Error(fmt.Sprintf("Error posting message: %v", err))
}
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("matrix:%s", m.roomID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+85 -32
View File
@@ -6,11 +6,11 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/mudler/LocalAGI/pkg/xstrings"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
@@ -152,34 +152,34 @@ func replaceUserIDsWithNamesInMessage(api *slack.Client, message string) string
func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) {
for _, state := range j.State {
// coming from the browser agent
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
state := historyStruct.States[len(historyStruct.States)-1]
// Decode base64 screenshot and upload to Slack
if state.Screenshot != "" {
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
if err != nil {
xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
continue
}
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
// state := historyStruct.States[len(historyStruct.States)-1]
// // Decode base64 screenshot and upload to Slack
// if state.Screenshot != "" {
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
// if err != nil {
// xlog.Error(fmt.Sprintf("Error decoding screenshot: %v", err))
// continue
// }
data := string(screenshotData)
// Upload the file to Slack
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(screenshotData),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: "screenshot.png",
InitialComment: "Browser Agent Screenshot",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
continue
}
}
}
}
// data := string(screenshotData)
// // Upload the file to Slack
// _, err = api.UploadFileV2(slack.UploadFileV2Parameters{
// Reader: bytes.NewReader(screenshotData),
// FileSize: len(data),
// ThreadTimestamp: ts,
// Channel: channelID,
// Filename: "screenshot.png",
// InitialComment: "Browser Agent Screenshot",
// })
// if err != nil {
// xlog.Error(fmt.Sprintf("Error uploading screenshot: %v", err))
// continue
// }
// }
// }
// }
// coming from the search action
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
@@ -204,6 +204,59 @@ func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, c
attachments = append(attachments, attachment)
}
}
// coming from the generate_song action (local file paths)
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error(fmt.Sprintf("Error reading song file %s: %v", path, err))
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "audio"
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: filename,
InitialComment: "Generated song",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading song to Slack: %v", err))
}
}
}
// coming from the generate_pdf action (local file paths)
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error(fmt.Sprintf("Error reading PDF file %s: %v", path, err))
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
_, err = api.UploadFileV2(slack.UploadFileV2Parameters{
Reader: bytes.NewReader(data),
FileSize: len(data),
ThreadTimestamp: ts,
Channel: channelID,
Filename: filename,
InitialComment: "Generated PDF document",
})
if err != nil {
xlog.Error(fmt.Sprintf("Error uploading PDF to Slack: %v", err))
}
}
}
}
return
}
@@ -735,12 +788,12 @@ func (t *Slack) Start(a *agent.Agent) {
if t.channelID != "" {
xlog.Debug(fmt.Sprintf("Listening for messages in channel %s", t.channelID))
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(slack)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(slack)", "message", ccm.Message.Content)
_, _, err := api.PostMessage(t.channelID,
slack.MsgOptionLinkNames(true),
slack.MsgOptionEnableLinkUnfurl(),
slack.MsgOptionText(ccm.Content, true),
slack.MsgOptionText(ccm.Message.Content, true),
slack.MsgOptionPostMessageParameters(postMessageParams),
)
if err != nil {
@@ -749,7 +802,7 @@ func (t *Slack) Start(a *agent.Agent) {
a.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("slack:%s", t.channelID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+150 -29
View File
@@ -10,7 +10,9 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -20,7 +22,6 @@ import (
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/localoperator"
"github.com/mudler/LocalAGI/pkg/xstrings"
"github.com/mudler/LocalAGI/services/actions"
"github.com/mudler/xlog"
@@ -535,6 +536,30 @@ func sendAudioToTelegram(ctx context.Context, b *bot.Bot, chatID int64, audioDat
return nil
}
// sendSongToTelegram reads a song file from path and sends it to Telegram as audio.
func sendSongToTelegram(ctx context.Context, b *bot.Bot, chatID int64, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading song file: %w", err)
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "audio"
}
_, err = b.SendAudio(ctx, &bot.SendAudioParams{
ChatID: chatID,
Audio: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated song",
})
if err != nil {
return fmt.Errorf("error sending audio: %w", err)
}
return nil
}
// handleMultimediaContent processes and sends multimedia content from the agent's response
func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, res *types.JobResult) ([]string, error) {
var urls []string
@@ -555,33 +580,72 @@ func (t *Telegram) handleMultimediaContent(ctx context.Context, chatID int64, re
}
}
// Handle browser agent screenshots
if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
if historyStruct, ok := history.(*localoperator.StateHistory); ok {
state := historyStruct.States[len(historyStruct.States)-1]
if state.Screenshot != "" {
// Decode base64 screenshot
screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
if err != nil {
xlog.Error("Error decoding screenshot", "error", err)
continue
}
// Send screenshot with caption
_, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
ChatID: chatID,
Photo: &models.InputFileUpload{
Filename: "screenshot.png",
Data: bytes.NewReader(screenshotData),
},
Caption: "Browser Agent Screenshot",
})
if err != nil {
xlog.Error("Error sending screenshot", "error", err)
}
// Handle songs from generate_song action (local file paths)
if songPaths, exists := state.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
xlog.Debug("Sending song", "path", path)
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
xlog.Error("Error sending song", "error", err)
}
}
}
// Handle PDFs from generate_pdf action (local file paths)
if pdfPaths, exists := state.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading PDF file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
xlog.Debug("Sending PDF document", "filename", filename, "size", len(data))
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated PDF",
})
if err != nil {
xlog.Error("Error sending PDF", "error", err)
}
}
}
// Handle browser agent screenshots
// if history, exists := state.Metadata[actions.MetadataBrowserAgentHistory]; exists {
// if historyStruct, ok := history.(*localoperator.StateHistory); ok {
// state := historyStruct.States[len(historyStruct.States)-1]
// if state.Screenshot != "" {
// // Decode base64 screenshot
// screenshotData, err := base64.StdEncoding.DecodeString(state.Screenshot)
// if err != nil {
// xlog.Error("Error decoding screenshot", "error", err)
// continue
// }
// // Send screenshot with caption
// _, err = t.bot.SendPhoto(ctx, &bot.SendPhotoParams{
// ChatID: chatID,
// Photo: &models.InputFileUpload{
// Filename: "screenshot.png",
// Data: bytes.NewReader(screenshotData),
// },
// Caption: "Browser Agent Screenshot",
// })
// if err != nil {
// xlog.Error("Error sending screenshot", "error", err)
// }
// }
// }
// }
}
return urls, nil
@@ -855,11 +919,68 @@ func (t *Telegram) Start(a *agent.Agent) {
if t.channelID != "" {
// handle new conversations
a.AddSubscriber(func(ccm openai.ChatCompletionMessage) {
xlog.Debug("Subscriber(telegram)", "message", ccm.Content)
a.AddSubscriber(func(ccm *types.ConversationMessage) {
xlog.Debug("Subscriber(telegram)", "message", ccm.Message.Content)
// First, handle any multimedia content from metadata
if ccm.Metadata != nil {
// Handle images from gen image actions
if imagesUrls, exists := ccm.Metadata[actions.MetadataImages]; exists {
for _, url := range xstrings.UniqueSlice(imagesUrls.([]string)) {
xlog.Debug("Sending photo from new conversation", "url", url)
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
if err := sendImageToTelegram(ctx, t.bot, chatID, url); err != nil {
xlog.Error("Error handling image", "error", err)
}
}
}
// Handle songs from generate_song action (local file paths)
if songPaths, exists := ccm.Metadata[actions.MetadataSongs]; exists {
for _, path := range xstrings.UniqueSlice(songPaths.([]string)) {
xlog.Debug("Sending song from new conversation", "path", path)
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
if err := sendSongToTelegram(ctx, t.bot, chatID, path); err != nil {
xlog.Error("Error sending song", "error", err)
}
}
}
// Handle PDFs from generate_pdf action (local file paths)
if pdfPaths, exists := ccm.Metadata[actions.MetadataPDFs]; exists {
for _, path := range xstrings.UniqueSlice(pdfPaths.([]string)) {
data, err := os.ReadFile(path)
if err != nil {
xlog.Error("Error reading PDF file", "path", path, "error", err)
continue
}
filename := filepath.Base(path)
if filename == "" || filename == "." {
filename = "document.pdf"
}
xlog.Debug("Sending PDF document from new conversation", "filename", filename, "size", len(data))
chatID, _ := strconv.ParseInt(t.channelID, 10, 64)
_, err = t.bot.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: filename,
Data: bytes.NewReader(data),
},
Caption: "Generated PDF",
})
if err != nil {
xlog.Error("Error sending PDF", "error", err)
}
}
}
}
// Then send the text message
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: t.channelID,
Text: ccm.Content,
Text: ccm.Message.Content,
})
if err != nil {
xlog.Error("Error sending message", "error", err)
@@ -869,7 +990,7 @@ func (t *Telegram) Start(a *agent.Agent) {
t.agent.SharedState().ConversationTracker.AddMessage(
fmt.Sprintf("telegram:%s", t.channelID),
openai.ChatCompletionMessage{
Content: ccm.Content,
Content: ccm.Message.Content,
Role: "assistant",
},
)
+2 -2
View File
@@ -122,7 +122,7 @@ func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) fu
dynamicPromptsFound := dynamicPrompts(customDirectory, existingDynamicPromptsConfigs)
memoryFilePath := memoryPath(a.Name, dynamicConfig)
memoryIdxPath := memoryIndexPath(a.Name, dynamicConfig)
promptblocks := []agent.DynamicPrompt{}
for _, c := range a.DynamicPrompts {
@@ -137,7 +137,7 @@ func DynamicPrompts(dynamicConfig map[string]string) func(*state.AgentConfig) fu
}
promptblocks = append(promptblocks, prompt)
case DynamicPromptMemory:
_, memory, _ := actions.NewMemoryActions(memoryFilePath, dynamicConfig)
_, memory, _, _ := actions.NewMemoryActions(memoryIdxPath, dynamicConfig)
promptblocks = append(promptblocks,
prompts.NewMemoryPrompt(config, memory),
+94 -116
View File
@@ -6,49 +6,49 @@
"name": "react-ui",
"dependencies": {
"highlight.js": "^11.11.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/react": "^19.2.7",
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"react-router-dom": "^7.11.0",
"vite": "^7.3.0",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"react-router-dom": "^7.13.0",
"vite": "^7.3.1",
},
},
},
"packages": {
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.27.5", "", {}, "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
"@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="],
@@ -56,11 +56,11 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA=="],
@@ -116,21 +116,19 @@
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-array": ["@eslint/config-array@0.23.1", "", { "dependencies": { "@eslint/object-schema": "^3.0.1", "debug": "^4.3.1", "minimatch": "^10.1.1" } }, "sha512-uVSdg/V4dfQmTjJzR0szNczjOH/J+FyUMMjYtr07xFRXR7EDf9i1qdxrD0VusZH9knj1/ecxzCQQxyic5NzAiA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@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@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/core": ["@eslint/core@1.1.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.1", "", {}, "sha512-P9cq2dpr+LU8j3qbLygLcSZrl2/ds/pUpfnHNNuk5HW7mnngHs+6WSq5C9mO3rqRX8A1poxqLTC9cu0KOyJlBg=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.0", "", { "dependencies": { "@eslint/core": "^1.1.0", "levn": "^0.4.1" } }, "sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -140,6 +138,10 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.2", "", {}, "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="],
"@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="],
"@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -152,7 +154,7 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.50.0", "", { "os": "android", "cpu": "arm" }, "sha512-lVgpeQyy4fWN5QYebtW4buT/4kn4p4IJ+kDNB4uYNT5b8c8DLJDg6titg20NIg7E8RWwdWZORW6vUFfrLyG3KQ=="],
@@ -204,15 +206,17 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@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-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"@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=="],
@@ -220,28 +224,10 @@
"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=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"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=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001714", "", {}, "sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
@@ -262,19 +248,19 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@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", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.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", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint": ["eslint@10.0.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.0", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.0", "@eslint/plugin-kit": "^0.6.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.0", "eslint-visitor-keys": "^5.0.0", "espree": "^11.1.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.1.1", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-O0piBKY36YSJhlFSG8p9VUdPV/SxxS4FYDWVpr/9GJuMaepzwlf4J8I4ov1b+ySQfDTPhc3DtLaxcT1fN0yqCg=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.0", "", { "peerDependencies": { "eslint": ">=9" } }, "sha512-ZYvmh7VfVgqR/7wR71I3Zl6hK/C5CcxdWYKZSpHawS5JCNgE4efhQWg/+/WPpgGAp9Ngp/rRZYyaIwmPQBq/lA=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-scope": ["eslint-scope@9.1.0", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.0", "", {}, "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"espree": ["espree@11.1.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
@@ -304,9 +290,7 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"globals": ["globals@17.3.0", "", {}, "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
@@ -316,8 +300,6 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -328,8 +310,6 @@
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -346,11 +326,9 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -366,8 +344,6 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -382,17 +358,15 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.11.0", "", { "dependencies": { "react-router": "7.11.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"rollup": ["rollup@4.50.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.50.0", "@rollup/rollup-android-arm64": "4.50.0", "@rollup/rollup-darwin-arm64": "4.50.0", "@rollup/rollup-darwin-x64": "4.50.0", "@rollup/rollup-freebsd-arm64": "4.50.0", "@rollup/rollup-freebsd-x64": "4.50.0", "@rollup/rollup-linux-arm-gnueabihf": "4.50.0", "@rollup/rollup-linux-arm-musleabihf": "4.50.0", "@rollup/rollup-linux-arm64-gnu": "4.50.0", "@rollup/rollup-linux-arm64-musl": "4.50.0", "@rollup/rollup-linux-loongarch64-gnu": "4.50.0", "@rollup/rollup-linux-ppc64-gnu": "4.50.0", "@rollup/rollup-linux-riscv64-gnu": "4.50.0", "@rollup/rollup-linux-riscv64-musl": "4.50.0", "@rollup/rollup-linux-s390x-gnu": "4.50.0", "@rollup/rollup-linux-x64-gnu": "4.50.0", "@rollup/rollup-linux-x64-musl": "4.50.0", "@rollup/rollup-openharmony-arm64": "4.50.0", "@rollup/rollup-win32-arm64-msvc": "4.50.0", "@rollup/rollup-win32-ia32-msvc": "4.50.0", "@rollup/rollup-win32-x64-msvc": "4.50.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-/Zl4D8zPifNmyGzJS+3kVoyXeDeT/GrsJM94sACNg9RtUE0hrHa1bNPtRSrfHTMH5HjRzce6K7rlTh3Khiw+pw=="],
@@ -408,10 +382,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -420,7 +390,7 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -434,34 +404,18 @@
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/core/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="],
"@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"@babel/helper-module-transforms/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"@babel/helpers/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/generator/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/parser/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/template/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"@babel/template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/template/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@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=="],
@@ -478,19 +432,7 @@
"eslint-plugin-react-hooks/@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="],
"@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"@babel/helper-module-transforms/@babel/traverse/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@eslint/eslintrc/espree/acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
"@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
"@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="],
@@ -508,14 +450,50 @@
"@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"eslint-plugin-react-hooks/@babel/core/@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
"eslint-plugin-react-hooks/@babel/core/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
"eslint-plugin-react-hooks/@babel/core/@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-compilation-targets/@babel/compat-data": ["@babel/compat-data@7.27.5", "", {}, "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"eslint-plugin-react-hooks/@babel/core/@babel/template/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"eslint-plugin-react-hooks/@babel/core/@babel/template/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"eslint-plugin-react-hooks/@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.3", "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q=="],
"eslint-plugin-react-hooks/@babel/core/@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.5", "", { "dependencies": { "@babel/types": "^7.27.3" }, "bin": "./bin/babel-parser.js" }, "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
"eslint-plugin-react-hooks/@babel/core/@babel/helper-module-transforms/@babel/helper-module-imports/@babel/traverse/@babel/generator/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
}
}
+10 -10
View File
@@ -10,20 +10,20 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"highlight.js": "^11.11.1"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/react": "^19.2.7",
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^10.0.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"react-router-dom": "^7.11.0",
"vite": "^7.3.0"
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.3.0",
"react-router-dom": "^7.13.0",
"vite": "^7.3.1"
}
}
+885 -1425
View File
File diff suppressed because it is too large Load Diff
+11 -11
View File
@@ -22,19 +22,19 @@ function App() {
return (
<div className="app-container">
{/* Navigation Menu */}
<nav className="main-nav" style={{ backgroundColor: 'var(--darker-bg)', borderBottom: '1px solid var(--medium-bg)', zIndex: 10 }}>
<nav className="main-nav">
<div className="container">
<div className="nav-content">
<div className="logo-container">
{/* Logo with glow effect */}
{/* 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> */}
{/* <span className="logo-text">LocalAGI</span> */}
</Link>
</div>
<div className="desktop-menu">
<ul className="nav-links">
<li>
@@ -59,22 +59,22 @@ function App() {
</li>
</ul>
</div>
<div className="">
<span className="status-indicator"></span>
<span className="status-text">State: <span className="status-value">active</span></span>
</div>
<div className="mobile-menu-toggle" onClick={toggleMobileMenu}>
<i className="fas fa-bars"></i>
</div>
</div>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="mobile-menu" style={{ backgroundColor: 'var(--darker-bg)', borderTop: '1px solid var(--medium-bg)' }}>
<div className="mobile-menu">
<ul className="mobile-nav-links">
<li>
<Link to="/" className="mobile-nav-link" onClick={() => setMobileMenuOpen(false)}>
@@ -99,21 +99,21 @@ function App() {
</ul>
</div>
)}
{/* Toast Notification */}
{toast.visible && (
<div className={`toast ${toast.type}`}>
<span>{toast.message}</span>
</div>
)}
{/* Main Content Area */}
<main className="main-content">
<div className="container">
<Outlet context={{ showToast }} />
</div>
</main>
</div>
)
}
+6 -3
View File
@@ -29,7 +29,10 @@ const AgentForm = ({
// Handle input changes
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target.name.target;
const { value, type, checked } = e.target;
const name = e.target.name;
// Guard: name must be a string (some sections pass synthetic events where name can be wrong)
if (typeof name !== 'string') return;
// Convert value to number if it's a number input
const processedValue = type === 'number' ? Number(value) : value;
@@ -272,7 +275,7 @@ const AgentForm = ({
</div>
<div style={{ display: activeSection === 'mcp-section' ? 'block' : 'none' }}>
<MCPServersSection formData={formData} handleAddMCPServer={handleAddMCPServer} handleRemoveMCPServer={handleRemoveMCPServer} handleMCPServerChange={handleMCPServerChange} />
<MCPServersSection formData={formData} handleAddMCPServer={handleAddMCPServer} handleRemoveMCPServer={handleRemoveMCPServer} handleMCPServerChange={handleMCPServerChange} handleInputChange={handleInputChange} metadata={metadata} />
</div>
<div style={{ display: activeSection === 'memory-section' ? 'block' : 'none' }}>
@@ -327,7 +330,7 @@ const AgentForm = ({
</div>
<div style={{ display: activeSection === 'mcp-section' ? 'block' : 'none' }}>
<MCPServersSection formData={formData} handleAddMCPServer={handleAddMCPServer} handleRemoveMCPServer={handleRemoveMCPServer} handleMCPServerChange={handleMCPServerChange} />
<MCPServersSection formData={formData} handleAddMCPServer={handleAddMCPServer} handleRemoveMCPServer={handleRemoveMCPServer} handleMCPServerChange={handleMCPServerChange} handleInputChange={handleInputChange} metadata={metadata} />
</div>
<div style={{ display: activeSection === 'memory-section' ? 'block' : 'none' }}>
@@ -13,21 +13,23 @@ const AdvancedSettingsSection = ({ formData, handleInputChange, metadata }) => {
// Get fields from metadata
const fields = metadata?.AdvancedSettingsSection || [];
// Handle field value changes
const handleFieldChange = (name, value) => {
// Handle field value changes (FormField passes the event)
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = fields.find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked: value === 'true'
checked
}
});
} else {
handleInputChange({
target: {
name,
type,
value
}
});
@@ -30,21 +30,23 @@ const BasicInfoSection = ({ formData, handleInputChange, isEdit, isGroupForm, me
return field;
}) || [];
// Handle field value changes
const handleFieldChange = (name, value) => {
// Handle field value changes (FormField passes the event)
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = fields.find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked: value === 'true'
checked
}
});
} else {
handleInputChange({
target: {
name,
type,
value
}
});
@@ -1,6 +1,42 @@
import React from 'react';
import React, { useMemo } from 'react';
import FormFieldDefinition from '../common/FormFieldDefinition';
// Parse mcp_stdio_servers JSON string to array of { name, command, args, env }
function parseStdioJson(str) {
if (!str || typeof str !== 'string') return [];
try {
const parsed = JSON.parse(str);
const mcpServers = parsed?.mcpServers || {};
return Object.entries(mcpServers).map(([name, s]) => ({
name: name || '',
command: s?.command ?? '',
args: Array.isArray(s?.args) ? [...s.args] : [],
env: s?.env && typeof s.env === 'object' && !Array.isArray(s.env) ? { ...s.env } : {},
}));
} catch {
return [];
}
}
// Build JSON string from array of STDIO servers (unique keys: name or server0, server1, etc.)
function buildStdioJson(list) {
const mcpServers = {};
const usedKeys = new Set();
list.forEach((item, index) => {
let key = (item.name && item.name.trim()) ? item.name.trim() : `server${index}`;
while (usedKeys.has(key)) {
key = `${key}_${index}`;
}
usedKeys.add(key);
mcpServers[key] = {
command: item.command || '',
args: item.args || [],
env: item.env && typeof item.env === 'object' ? { ...item.env } : {},
};
});
return JSON.stringify({ mcpServers }, null, 2);
}
/**
* MCP Servers section of the agent form
*/
@@ -8,8 +44,118 @@ const MCPServersSection = ({
formData,
handleAddMCPServer,
handleRemoveMCPServer,
handleMCPServerChange
handleMCPServerChange,
handleInputChange,
metadata
}) => {
// MCP configuration fields excluding mcp_stdio_servers (handled by dynamic STDIO block below)
const mcpFields = useMemo(
() => (metadata?.MCPSection || []).filter((f) => f.name !== 'mcp_stdio_servers'),
[metadata?.MCPSection]
);
// Parsed STDIO servers list derived from formData.mcp_stdio_servers
const stdioList = useMemo(
() => parseStdioJson(formData.mcp_stdio_servers),
[formData.mcp_stdio_servers]
);
const setStdioJson = (newList) => {
handleInputChange({
target: { name: 'mcp_stdio_servers', value: buildStdioJson(newList) },
});
};
const addStdioServer = () => {
setStdioJson([...stdioList, { name: '', command: '', args: [], env: {} }]);
};
const removeStdioServer = (index) => {
setStdioJson(stdioList.filter((_, i) => i !== index));
};
const updateStdioServer = (index, field, value) => {
const next = stdioList.map((s, i) =>
i === index ? { ...s, [field]: value } : s
);
setStdioJson(next);
};
const addArg = (serverIndex, argValue = '') => {
const server = stdioList[serverIndex];
if (!server) return;
updateStdioServer(serverIndex, 'args', [...(server.args || []), argValue]);
};
const removeArg = (serverIndex, argIndex) => {
const server = stdioList[serverIndex];
if (!server) return;
const args = (server.args || []).filter((_, i) => i !== argIndex);
updateStdioServer(serverIndex, 'args', args);
};
const updateArg = (serverIndex, argIndex, value) => {
const server = stdioList[serverIndex];
if (!server) return;
const args = [...(server.args || [])];
args[argIndex] = value;
updateStdioServer(serverIndex, 'args', args);
};
const addEnv = (serverIndex, key = '', value = '') => {
const server = stdioList[serverIndex];
if (!server) return;
const env = { ...(server.env || {}), [key || `key_${Date.now()}`]: value };
updateStdioServer(serverIndex, 'env', env);
};
const removeEnv = (serverIndex, envKey) => {
const server = stdioList[serverIndex];
if (!server) return;
const env = { ...(server.env || {}) };
delete env[envKey];
updateStdioServer(serverIndex, 'env', env);
};
const updateEnvKey = (serverIndex, oldKey, newKey) => {
const server = stdioList[serverIndex];
if (!server) return;
const env = { ...(server.env || {}) };
const val = env[oldKey];
delete env[oldKey];
env[newKey || oldKey] = val;
updateStdioServer(serverIndex, 'env', env);
};
const updateEnvValue = (serverIndex, envKey, value) => {
const server = stdioList[serverIndex];
if (!server) return;
const env = { ...(server.env || {}), [envKey]: value };
updateStdioServer(serverIndex, 'env', env);
};
// Handle MCP configuration field value changes (FormField passes the event)
const handleMCPFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = mcpFields.find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked
}
});
} else {
handleInputChange({
target: {
name,
value
}
});
}
};
// Define field definitions for each MCP server
const getServerFields = () => [
{
@@ -43,12 +189,149 @@ const MCPServersSection = ({
<p className="section-description">
Configure MCP servers for this agent.
</p>
<div className="mcp-servers-container">
{mcpFields.length > 0 && (
<div className="mcp-config-fields mb-4">
<h4 className="subsection-title">MCP configuration</h4>
<FormFieldDefinition
fields={mcpFields}
values={formData}
onChange={handleMCPFieldChange}
idPrefix="mcp_"
/>
</div>
)}
<div className="mcp-block mcp-stdio-block mb-4">
<h4 className="subsection-title">MCP STDIO Servers</h4>
<p className="section-description">
Configure MCP servers that run as local commands (e.g. docker run). Each server has a name, command, args, and env.
</p>
{stdioList.map((server, index) => (
<div key={index} className="mcp-server-item mb-4">
<div className="mcp-server-header">
<input
type="text"
className="form-control stdio-server-name-input"
placeholder="Server name (e.g. memory)"
value={server.name || ''}
onChange={(e) => updateStdioServer(index, 'name', e.target.value)}
aria-label="STDIO server name"
style={{ flex: 1, marginRight: '12px' }}
/>
<button
type="button"
className="action-btn delete-btn"
onClick={() => removeStdioServer(index)}
aria-label="Remove STDIO server"
>
<i className="fas fa-times"></i>
</button>
</div>
<div className="form-group mb-3">
<label htmlFor={`stdio_cmd_${index}`}>Command</label>
<input
type="text"
id={`stdio_cmd_${index}`}
className="form-control"
placeholder="e.g. docker"
value={server.command || ''}
onChange={(e) => updateStdioServer(index, 'command', e.target.value)}
/>
</div>
<div className="form-group mb-3">
<label>Args</label>
{(server.args || []).map((arg, argIndex) => (
<div key={argIndex} className="input-group mb-2" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
placeholder="Argument"
value={arg}
onChange={(e) => updateArg(index, argIndex, e.target.value)}
/>
<button
type="button"
className="action-btn delete-btn"
onClick={() => removeArg(index, argIndex)}
aria-label="Remove arg"
>
<i className="fas fa-times"></i>
</button>
</div>
))}
<button
type="button"
className="action-btn"
onClick={() => addArg(index)}
>
<i className="fas fa-plus"></i> Add Arg
</button>
</div>
<div className="form-group mb-3">
<label>Environment</label>
{Object.entries(server.env || {}).map(([envKey, envVal]) => (
<div key={envKey} className="input-group mb-2" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<input
type="text"
className="form-control"
placeholder="Key"
value={envKey}
onChange={(e) => updateEnvKey(index, envKey, e.target.value)}
/>
<input
type="text"
className="form-control"
placeholder="Value"
value={envVal}
onChange={(e) => updateEnvValue(index, envKey, e.target.value)}
/>
<button
type="button"
className="action-btn delete-btn"
onClick={() => removeEnv(index, envKey)}
aria-label="Remove env"
>
<i className="fas fa-times"></i>
</button>
</div>
))}
<button
type="button"
className="action-btn"
onClick={() => addEnv(index)}
>
<i className="fas fa-plus"></i> Add Env
</button>
</div>
</div>
))}
<button type="button" className="action-btn" onClick={addStdioServer}>
<i className="fas fa-plus"></i> Add MCP STDIO Server
</button>
<details className="mt-3">
<summary className="subsection-title" style={{ cursor: 'pointer' }}>Edit as JSON</summary>
<textarea
className="form-control mt-2"
rows={8}
placeholder='{"mcpServers":{"memory":{"command":"docker","args":["run","-i","--rm",...],"env":{...}}}}'
value={formData.mcp_stdio_servers || ''}
onChange={(e) => handleInputChange({ target: { name: 'mcp_stdio_servers', value: e.target.value } })}
spellCheck={false}
style={{ fontFamily: 'monospace', fontSize: '12px' }}
/>
</details>
</div>
<div className="mcp-block mcp-http-block mcp-servers-container">
<h4 className="subsection-title">MCP HTTP Servers</h4>
<p className="section-description">
Configure MCP servers that connect over HTTP (URL and optional API key).
</p>
{formData.mcp_servers && formData.mcp_servers.map((server, index) => (
<div key={index} className="mcp-server-item mb-4">
<div className="mcp-server-header">
<h4>MCP Server #{index + 1}</h4>
<h4>MCP HTTP Server #{index + 1}</h4>
<button
type="button"
className="action-btn delete-btn"
@@ -72,7 +355,7 @@ const MCPServersSection = ({
className="action-btn"
onClick={handleAddMCPServer}
>
<i className="fas fa-plus"></i> Add MCP Server
<i className="fas fa-plus"></i> Add MCP HTTP Server
</button>
</div>
</div>
@@ -13,21 +13,23 @@ const MemorySettingsSection = ({ formData, handleInputChange, metadata }) => {
// Get fields from metadata
const fields = metadata?.MemorySettingsSection || [];
// Handle field value changes
const handleFieldChange = (name, value) => {
// Handle field value changes (FormField passes the event)
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = fields.find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked: value === 'true'
checked
}
});
} else {
handleInputChange({
target: {
name,
type,
value
}
});
@@ -13,21 +13,23 @@ const ModelSettingsSection = ({ formData, handleInputChange, metadata }) => {
// Get fields from metadata
const fields = metadata?.ModelSettingsSection || [];
// Handle field value changes
const handleFieldChange = (name, value) => {
// Handle field value changes (FormField passes the event)
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = fields.find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked: value === 'true'
checked
}
});
} else {
handleInputChange({
target: {
name,
type,
value
}
});
@@ -34,21 +34,23 @@ const PromptsGoalsSection = ({
return metadata.PromptsGoalsSection;
};
// Handle field value changes
const handleFieldChange = (name, value) => {
// Handle field value changes (FormField passes the event)
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
const field = getFields().find(f => f.name === name);
if (field && field.type === 'checkbox') {
handleInputChange({
target: {
name,
type: 'checkbox',
checked: value === 'true'
checked
}
});
} else {
handleInputChange({
target: {
name,
type,
value
}
});
+78 -33
View File
@@ -1,11 +1,13 @@
/* LocalAGI Base Styles */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color-scheme: dark;
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -13,56 +15,99 @@
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
color: var(--color-primary);
text-decoration: none;
transition: color var(--duration-fast) var(--ease-default);
}
a:hover {
color: #535bf2;
color: var(--color-primary-hover);
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
background-color: var(--color-bg-primary);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
font-size: 2rem;
font-weight: 700;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
button {
border-radius: 8px;
border: 1px solid transparent;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
background-color: var(--color-bg-secondary);
color: var(--color-text-primary);
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
transition: all var(--duration-fast) var(--ease-default);
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
button:hover {
border-color: var(--color-primary);
background-color: var(--color-primary-light);
}
button:focus,
button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Selection styling */
::selection {
background-color: var(--color-primary);
color: var(--color-text-inverse);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
+3 -2
View File
@@ -2,13 +2,14 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import './theme.css'
import './index.css'
import './App.css'
// Add the Google Fonts for the cyberpunk styling
// Add professional Google Fonts
const fontLink = document.createElement('link');
fontLink.rel = 'stylesheet';
fontLink.href = 'https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;700&family=Permanent+Marker&display=swap';
fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';
document.head.appendChild(fontLink);
// Add Font Awesome for icons
+70 -77
View File
@@ -17,7 +17,7 @@ function AgentsList() {
if (!response.ok) {
throw new Error(`Server responded with status: ${response.status}`);
}
const data = await response.json();
setAgents(data.agents || []);
setStatuses(data.statuses || {});
@@ -38,18 +38,18 @@ function AgentsList() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
if (response.ok) {
// Update local state
setStatuses(prev => ({
...prev,
[name]: !isActive
}));
// Show success toast
const action = isActive ? 'paused' : 'started';
showToast(`Agent "${name}" ${action} successfully`, 'success');
// Refresh the agents list to ensure we have the latest data
fetchAgents();
} else {
@@ -67,13 +67,13 @@ function AgentsList() {
if (!confirm(`Are you sure you want to delete agent "${name}"? This action cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/agent/${name}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
});
if (response.ok) {
// Remove from local state
setAgents(prev => prev.filter(agent => agent !== name));
@@ -82,7 +82,7 @@ function AgentsList() {
delete newStatuses[name];
return newStatuses;
});
// Show success toast
showToast(`Agent "${name}" deleted successfully`, 'success');
} else {
@@ -130,76 +130,69 @@ function AgentsList() {
</header>
{agents.length > 0 ? (
<div className="agents-grid">
{agents.map(name => (
<div key={name} className="agent-card" data-agent={name} data-active={statuses[name]}>
<div className="agent-content text-center">
<div className="avatar-container mb-4">
<img
src={`/avatars/${name}.png`}
alt={name}
className="w-24 h-24 rounded-full"
style={{
border: '2px solid var(--primary)',
boxShadow: 'var(--neon-glow)',
display: 'none',
margin: '0 auto'
}}
onLoad={(e) => {
e.target.style.display = 'block';
e.target.nextElementSibling.style.display = 'none';
}}
onError={(e) => {
e.target.style.display = 'none';
e.target.nextElementSibling.style.display = 'flex';
}}
/>
<div className="avatar-placeholder" style={{margin: '0 auto'}}>
<span className="placeholder-text"><i className="fas fa-sync fa-spin"></i></span>
</div>
</div>
<div className="agent-header">
<h2>{name}</h2>
<span className={`status-badge ${statuses[name] ? 'active' : 'inactive'}`}>
{statuses[name] ? 'Active' : 'Paused'}
</span>
</div>
<div className="agent-actions">
<Link to={`/talk/${name}`} className="action-btn chat-btn">
<i className="fas fa-comment"></i> Chat
</Link>
<Link to={`/status/${name}`} className="action-btn status-btn">
<i className="fas fa-chart-line"></i> Status
</Link>
<Link to={`/settings/${name}`} className="action-btn settings-btn">
<i className="fas fa-cog"></i> Settings
</Link>
</div>
<div className="agent-actions mt-2">
<button
className="action-btn toggle-btn"
onClick={() => toggleAgentStatus(name, statuses[name])}
>
{statuses[name] ? (
<><i className="fas fa-pause"></i> Pause</>
) : (
<><i className="fas fa-play"></i> Start</>
)}
</button>
<button
className="action-btn delete-btn"
onClick={() => deleteAgent(name)}
>
<i className="fas fa-trash-alt"></i> Delete
</button>
</div>
</div>
</div>
))}
<div className="agents-table-container">
<table className="agents-table">
<thead>
<tr>
<th>Agent Name</th>
<th>Status</th>
<th>Quick Actions</th>
<th>Management</th>
</tr>
</thead>
<tbody>
{agents.map(name => (
<tr key={name} data-agent={name} data-active={statuses[name]}>
<td>
<div className="agent-info">
<span className="agent-name-main">{name}</span>
</div>
</td>
<td>
<span className={`status-badge ${statuses[name] ? 'active' : 'inactive'}`}>
{statuses[name] ? 'Active' : 'Paused'}
</span>
</td>
<td>
<div className="agent-table-actions">
<Link to={`/talk/${name}`} className="action-btn chat-btn" title="Chat">
<i className="fas fa-comment"></i> Chat
</Link>
<Link to={`/status/${name}`} className="action-btn status-btn" title="Status">
<i className="fas fa-chart-line"></i> Status
</Link>
<Link to={`/settings/${name}`} className="action-btn settings-btn" title="Settings">
<i className="fas fa-cog"></i> Settings
</Link>
</div>
</td>
<td>
<div className="agent-table-actions">
<button
className="action-btn toggle-btn"
onClick={() => toggleAgentStatus(name, statuses[name])}
title={statuses[name] ? "Pause Agent" : "Start Agent"}
>
{statuses[name] ? (
<><i className="fas fa-pause"></i> Pause</>
) : (
<><i className="fas fa-play"></i> Start</>
)}
</button>
<button
className="action-btn delete-btn"
onClick={() => deleteAgent(name)}
title="Delete Agent"
>
<i className="fas fa-trash-alt"></i> Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="no-agents">
+5 -31
View File
@@ -59,9 +59,9 @@ function Home() {
<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">
@@ -87,7 +87,7 @@ function Home() {
<p>View and manage your list of agents, including detailed profiles and statistics.</p>
</div>
</Link>
{/* Card for Create Agent */}
<Link to="/create" className="card-link">
<div className="card">
@@ -95,7 +95,7 @@ function Home() {
<p>Create a new intelligent agent with custom behaviors, connectors, and actions.</p>
</div>
</Link>
{/* Card for Actions Playground */}
<Link to="/actions-playground" className="card-link">
<div className="card">
@@ -103,7 +103,7 @@ function Home() {
<p>Explore and test available actions for your agents.</p>
</div>
</Link>
{/* Card for Group Create */}
<Link to="/group-create" className="card-link">
<div className="card">
@@ -122,32 +122,6 @@ function Home() {
</div>
{stats.agents.length > 0 && (
<div className="recent-agents">
<h2>Your Agents</h2>
<div className="cards-container">
{stats.agents.map((agent) => (
<div key={agent} className="card">
<div className={`status-badge ${stats.status[agent] ? 'status-active' : 'status-paused'}`}>
{stats.status[agent] ? 'Active' : 'Paused'}
</div>
<h2><i className="fas fa-robot"></i> {agent}</h2>
<div className="agent-actions">
<Link to={`/talk/${agent}`} className="action-btn chat-btn">
<i className="fas fa-comment"></i> Chat
</Link>
<Link to={`/settings/${agent}`} className="action-btn settings-btn">
<i className="fas fa-cog"></i> Settings
</Link>
<Link to={`/status/${agent}`} className="action-btn status-btn">
<i className="fas fa-chart-line"></i> Status
</Link>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
/* LocalAGI Theme - CSS Variables System */
/* Inspired by LocalAI's elegant professional design */
: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 */
--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-hover: #0D9488;
--color-secondary-light: rgba(20, 184, 166, 0.1);
/* Accent Colors */
--color-accent: #8B5CF6; /* Purple - special states */
--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 */
/* Text Colors */
--color-text-primary: #E5E7EB; /* Primary text */
--color-text-secondary: #94A3B8; /* Secondary text */
--color-text-muted: #64748B; /* Tertiary/muted text */
--color-text-disabled: #475569; /* Disabled text */
--color-text-inverse: #0F172A; /* Text on light backgrounds */
/* Border Colors */
--color-border: rgba(148, 163, 184, 0.12);
--color-border-subtle: rgba(148, 163, 184, 0.08);
--color-border-strong: rgba(56, 189, 248, 0.2);
--color-border-focus: rgba(56, 189, 248, 0.3);
/* Status Colors */
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-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;
/* Spacing Scale */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Legacy Variable Mappings (for backward compatibility) */
--primary: var(--color-primary);
--secondary: var(--color-secondary);
--tertiary: var(--color-accent);
--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;
}
+1 -2
View File
@@ -29,8 +29,7 @@ export default defineConfig(({ mode }) => {
'/chat': backendUrl,
'/status': backendUrl,
'/action': backendUrl,
'/actions': backendUrl,
'/avatars': backendUrl
'/actions': backendUrl
}
}
}
-8
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"math/rand"
"net/http"
"path/filepath"
"github.com/dave-gray101/v2keyauth"
fiber "github.com/gofiber/fiber/v2"
@@ -27,13 +26,6 @@ var reactUI embed.FS
func (app *App) registerRoutes(pool *state.AgentPool, webapp *fiber.App) {
// Static avatars in a.pooldir/avatars
webapp.Use("/avatars", filesystem.New(filesystem.Config{
Root: http.Dir(filepath.Join(app.config.StateDir, "avatars")),
// PathPrefix: "avatars",
Browse: true,
}))
if len(app.config.ApiKeys) > 0 {
kaConfig, err := GetKeyAuthConfig(app.config.ApiKeys)
if err != nil || kaConfig == nil {