diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a74ac5..6ad6cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog - FlowDeck +## v7.55.0 (2026-10-06) — Menu + : Connecteurs — socle (phase 5/8) + +### Added + +- **Table `agent_connectors`** (migration **32**) : `name`, `url`, + `secret_encrypted`, `enabled`, `status`, `detail` — colonnes **plates**, pas de + `config_json` (les scopes OAuth des phases 6-7 ajouteront le leur). +- **`app/services/connectors.py`** — 1 fichier (pas de package) : + - **3 connecteurs natifs** (`gitea`, `github`, `web`) servis à la volée, statut + dérivé de la config **sans réseau** (`native_state()`) ; + - CRUD des connecteurs personnalisés : l'URL passe par **`_validate_url` + (garde SSRF)** à la création, la clé est **chiffrée Fernet** (`encrypt_secret`) + et **jamais renvoyée** (seul `has_secret` l'est) ; + - `probe()` : appel de contrôle (`_get`, timeout 10 s, re-vérification de + l'hôte après redirection) qui **persiste** `status`/`detail` ; + - `connector_fetch()` : lecture pour l'LLM — API Gitea/GitHub (native), + recherche web (native) ou GET du connecteur personnalisé, borné à 20 000 car. +- **API** (`/api/agent/connectors`) — `GET` (catalogue), `POST` (création, 400 sur + URL privée), `PATCH` (name/enabled/secret), `DELETE`, `POST /connectors/probe` + ; session requise partout (401), CSRF global. **OpenAPI : 519 chemins**. +- **Outil LLM `connector_fetch`** (`tool_registry`, 26ᵉ outil) — args + `connector` (id / nom / kind), `path`, `query` ; connecteur désactivé ou + inconnu = erreur outil, **jamais une exception qui casse le run**. +- **Menu + : section « Connecteurs »** — catalogue avec badge de statut + (✓/✗/⚠/?), fiche par connecteur (Tester, Activer/Désactiver, Supprimer — les + 2 derniers masqués pour les natifs) et formulaire « Ajouter un connecteur + personnalisé » (nom, URL, clé en `type="password"`). + +### Tests + +- `tests/test_v755_connectors.py` — **13 tests** : 3 natifs listés, clé jamais + renvoyée + chiffrée en base (roundtrip Fernet), **5 URLs refusées** (localhost, + 127.0.0.1, metadata cloud, ftp:, file:), toggle persisté + 404, 401 sans + session, probe OK (clé bien envoyée) / probe erreur persistée, outil + `connector_fetch` (schéma, succès, désactivé, inconnu), câblage du menu. +- `test_v751`/`test_v753`/`test_v754` adaptés : **1 seule section « bientôt »** + (plugins). +- **Suite complète : 1306 passed / 0 failed** (`-n auto`), `ruff` 0, + `eslint` 0 problème. + + ## v7.54.0 (2026-10-06) — Menu + : Mémoire de l'agent (phase 4/8) ### Added diff --git a/ROADMAP.md b/ROADMAP.md index ea9a19c..9ebbd7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -284,7 +284,7 @@ Propriétés custom, AI keywords, sync API, 12 tables DB --- -## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-4 ✅ 2026-10-06 · phases 5-8 planifiées) +## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-5 ✅ 2026-10-06 · phases 6-8 planifiées) > **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition) > un menu à sections, tel que demandé : @@ -416,19 +416,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. - [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré (516 chemins), CHANGELOG, ce ROADMAP -### Phase 5 — v7.55.0 — Connecteurs : socle · effort L +### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06) -- [ ] **Table `agent_connectors`** (`kind`, `name`, `config`, credentials chiffrés, - `enabled`, `status`) -- [ ] **`app/services/connectors/`** — interface commune `probe() / list_sources() / fetch()` - + 3 adapters **natifs déjà présents** : Gitea, GitHub, Web (`web_search` / `fetch_url`, v7.46) -- [ ] **Menu « Connecteurs »** — « Parcourir les connecteurs » (catalogue + statut - connecté/déconnecté) et « Ajouter un connecteur personnalisé » (URL + clé + type - HTTP / MCP) -- [ ] **Tools agent** — un tool par connecteur exposé au LLM via `tool_registry` -- [ ] **Sécurité** — garde SSRF `_is_public_host` sur toute URL, credentials chiffrés, - session requise, CSRF sur les écritures -- [ ] **Tests** — CRUD, statut, vecteurs SSRF, schéma des tools +- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates** + (`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le + `config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes + OAuth des phases 6-7 ajouteront le leur) +- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package + `connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ; + 3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau** + (`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()` + (gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.) +- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge + (✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver, + Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » = + formulaire nom + URL + clé (`type="password"`) +- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` + + `query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de + schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur + désactivé ou inconnu = erreur outil, jamais une exception de run +- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel, + clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans + session sur les 5 routes, CSRF global +- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé + chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata + cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée, + outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section + « bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0 +- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré + (519 chemins), CHANGELOG, ce ROADMAP ### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L @@ -456,8 +472,8 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j. --- -*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 4 livrées le -2026-10-06 (v7.51.0 → v7.54.0)**, phases 5-8 à valider une par une avant « go ». +*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 5 livrées le +2026-10-06 (v7.51.0 → v7.55.0)**, phases 6-8 à valider une par une avant « go ». Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.* --- diff --git a/VERSION b/VERSION index ccb032e..3c08909 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.54.0 +7.55.0 diff --git a/app/main.py b/app/main.py index b51a843..2827062 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.54.0", + version="7.55.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index 19a24fd..8d0b475 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1586,6 +1586,27 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None: ) +@register(32, "v7.55.0: connecteurs de l'agent (socle)") +def _migration_agent_connectors(conn: sqlite3.Connection) -> None: + """Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici. + + Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des + phases 6-7 ajouteront leur propre colonne le moment venu. + """ + conn.execute( + """CREATE TABLE IF NOT EXISTS agent_connectors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret_encrypted TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, + status TEXT NOT NULL DEFAULT 'unknown', + detail TEXT NOT NULL DEFAULT '', + created_by INTEGER + )""" + ) + + @register(31, "v7.54.0: agent memory (mémoire de l'agent)") def _migration_agent_memory(conn: sqlite3.Connection) -> None: """Toggle par conversation + table des résumés mémorisés. diff --git a/app/routers/agent.py b/app/routers/agent.py index 76dc90f..dff5acc 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -15,7 +15,7 @@ from fastapi.responses import StreamingResponse from app.auth.session import get_current_user from app.config import settings from app.db import get_conn -from app.services import skill_gallery +from app.services import connectors, skill_gallery from app.services.agent_engine import AgentEngine, undo_action from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient from app.services.llm_config import ( @@ -633,6 +633,68 @@ def delete_skill(request: Request, skill_id: int): return {"id": skill_id, "status": "deleted"} +# ── Connecteurs (v7.55.0) ── +# ponytail : catalogue partagé au même titre que les skills (`list_skills` +# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée. + + +@router.get("/connectors") +def list_connectors_route(request: Request): + """Catalogue : 3 natifs (statut dérivé de la config) + personnels.""" + _current_user_id(request) + return {"connectors": connectors.list_connectors()} + + +@router.post("/connectors") +def create_connectors_route(request: Request, body: dict = Body(default={})): + """Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF).""" + _current_user_id(request) + try: + return connectors.create_connector( + body.get("name") or "", body.get("url") or "", str(body.get("secret") or "") + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/connectors/{connector_id}") +def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})): + _current_user_id(request) + try: + row = connectors.update_connector( + connector_id, + name=body.get("name"), + enabled=body.get("enabled"), + secret=body.get("secret"), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if row is None: + raise HTTPException(status_code=404, detail="Connecteur introuvable") + return row + + +@router.delete("/connectors/{connector_id}") +def delete_connectors_route(request: Request, connector_id: int): + _current_user_id(request) + if not connectors.delete_connector(connector_id): + raise HTTPException(status_code=404, detail="Connecteur introuvable") + return {"id": connector_id, "status": "deleted"} + + +@router.post("/connectors/probe") +async def probe_connectors_route(request: Request, body: dict = Body(default={})): + """Teste un connecteur (id, nom ou kind natif) et persiste le résultat.""" + _current_user_id(request) + target = str(body.get("connector") or "").strip() + if not target: + raise HTTPException(status_code=400, detail="connector est requis") + try: + return await connectors.probe(target) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + # ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ── diff --git a/app/services/connectors.py b/app/services/connectors.py new file mode 100644 index 0000000..ab30c20 --- /dev/null +++ b/app/services/connectors.py @@ -0,0 +1,255 @@ +"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF. + +3 connecteurs **natifs** (gitea, github, web) servis à la volée depuis la config, +plus des connecteurs **personnalisation** persistés (URL + clé) en base. + +ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par +provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7 +(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module. +""" + +from __future__ import annotations + +import logging + +from app.config import settings +from app.db import get_conn +from app.services.sso_provisioning import decrypt_secret, encrypt_secret + +logger = logging.getLogger(__name__) + +NATIVE_KINDS = ("gitea", "github", "web") +FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe) + + +# ── Natifs (config, sans réseau) ───────────────────────────────────────────── + +def native_state(kind: str) -> tuple[str, str]: + """(status, detail) d'un connecteur natif — dérivé de la config, sans réseau.""" + if kind == "gitea": + ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me") + return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré") + if kind == "github": + if settings.github_token: + return ("ok", "PAT configuré (30 req/min)") + return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)") + return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}") + + +def list_connectors() -> list[dict]: + """Catalogue : 3 natifs (toujours présents) + les connecteurs personnels.""" + out: list[dict] = [] + for kind in NATIVE_KINDS: + status, detail = native_state(kind) + out.append({ + "id": None, "builtin": True, "kind": kind, "name": kind.capitalize(), + "url": "", "enabled": True, "status": status, "detail": detail, + "has_secret": False, + }) + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name, url, secret_encrypted, enabled, status, detail " + "FROM agent_connectors ORDER BY id" + ).fetchall() + for r in rows: + out.append({ + "id": r["id"], "builtin": False, "kind": "custom", "name": r["name"], + "url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"], + "detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"]), + }) + return out + + +def get(connector_id: int) -> dict | None: + with get_conn() as conn: + r = conn.execute( + "SELECT id, name, url, secret_encrypted, enabled, status, detail " + "FROM agent_connectors WHERE id=?", (connector_id,) + ).fetchone() + if not r: + return None + return {"id": r["id"], "builtin": False, "kind": "custom", "name": r["name"], + "url": r["url"], "enabled": bool(r["enabled"]), "status": r["status"], + "detail": r["detail"] or "", "has_secret": bool(r["secret_encrypted"])} + + +# ── CRUD (personnels) ──────────────────────────────────────────────────────── + +def create_connector(name: str, url: str, secret: str = "") -> dict: + """Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).""" + from app.services.importers.url_fetch import _validate_url + + name = (name or "").strip() + if not name: + raise ValueError("name est requis") + url = _validate_url((url or "").strip()) # lève ValueError si hôte interne + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO agent_connectors (name, url, secret_encrypted, status, detail) " + "VALUES (?,?,?,?,?)", + (name, url, encrypt_secret(secret or ""), "unknown", ""), + ) + conn.commit() + cid = cur.lastrowid + return get(cid) + + +def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None: + row = get(connector_id) + if row is None: + return None + sets, params = [], [] + if name is not None: + name = str(name).strip() + if not name: + raise ValueError("name est requis") + sets.append("name=?") + params.append(name) + if enabled is not None: + sets.append("enabled=?") + params.append(1 if enabled else 0) + if secret is not None: + sets.append("secret_encrypted=?") + params.append(encrypt_secret(str(secret))) + if sets: + with get_conn() as conn: + params.append(connector_id) + conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params) + conn.commit() + return get(connector_id) + + +def delete_connector(connector_id: int) -> bool: + with get_conn() as conn: + cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,)) + conn.commit() + return cur.rowcount > 0 + + +# ── Réseau (toujours gardé SSRF) ───────────────────────────────────────────── + +def _headers(connector_id: int | None = None) -> dict: + """En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.""" + headers = {"User-Agent": "FlowDeck-Connectors/1.0"} + secret = "" + if connector_id: + with get_conn() as conn: + r = conn.execute( + "SELECT secret_encrypted FROM agent_connectors WHERE id=?", + (connector_id,), + ).fetchone() + secret = decrypt_secret((r["secret_encrypted"] if r else "") or "") + if secret: + headers["Authorization"] = f"Bearer {secret}" + headers["X-Api-Key"] = secret + return headers + + +async def _get(url: str, headers: dict | None = None) -> tuple[int, str]: + """GET gardé SSRF (validation + re-vérification après redirection). + + Point d'injection des tests : on monkeypatche ``connectors._get``. + """ + from app.services.http_client import shared_client + from app.services.importers.url_fetch import _is_public_host, _validate_url + + safe = _validate_url(url) + async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client: + resp = await client.get(safe) + if resp.url.host and not _is_public_host(resp.url.host): + raise ValueError("Redirection vers un hôte non autorisé") + return resp.status_code, (resp.text or "")[:FETCH_LIMIT] + + +def _resolve(connector: str) -> tuple[str, str | int]: + """« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind).""" + token = str(connector or "").strip() + if not token: + raise ValueError("connector est requis") + if token.isdigit(): + row = get(int(token)) + if row is None: + raise ValueError(f"Connecteur inconnu: {token}") + return ("custom", row["id"]) + low = token.lower() + if low in NATIVE_KINDS: + return (low, low) + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM agent_connectors WHERE lower(name)=?", (low,) + ).fetchone() + if row: + return ("custom", row["id"]) + raise ValueError(f"Connecteur inconnu: {token}") + + +async def probe(connector: str) -> dict: + """Teste un connecteur et **persiste** le résultat (personnel uniquement).""" + kind, ref = _resolve(connector) + try: + if kind == "gitea": + code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version", + {"Authorization": f"token {settings.gitea_token}"}) + status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}" + elif kind == "github": + code, _ = await _get("https://api.github.com/rate_limit", _gh_headers()) + status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}" + elif kind == "web": + status, detail = "ok", native_state("web")[1] + else: + row = get(int(ref)) + code, _ = await _get(row["url"], _headers(int(ref))) + status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}" + except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI + logger.info("Connector probe failed (%s): %s", connector, exc) + status, detail = "error", str(exc)[:200] + if kind == "custom": + with get_conn() as conn: + conn.execute( + "UPDATE agent_connectors SET status=?, detail=? WHERE id=?", + (status, detail, int(ref)), + ) + conn.commit() + return {"connector": connector, "status": status, "detail": detail} + + +def _gh_headers() -> dict: + headers = {"User-Agent": "FlowDeck-Connectors/1.0", + "Accept": "application/vnd.github+json"} + if settings.github_token: + headers["Authorization"] = f"Bearer {settings.github_token}" + return headers + + +async def connector_fetch(connector: str, path: str = "", query: str = "") -> dict: + """Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET.""" + kind, ref = _resolve(connector) + path = (path or "").strip() + if kind == "gitea": + base = settings.gitea_url.rstrip("/") + url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version" + code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"}) + elif kind == "github": + url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit" + code, text = await _get(url, _gh_headers()) + elif kind == "web": + from app.services.web_search import search_web + if not (query or "").strip(): + return {"status": "error", "text": "query est requis pour le connecteur web"} + results, provider = await search_web(query.strip(), 5) + text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}" + for r in results) or "Aucun résultat." + return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]} + else: + row = get(int(ref)) + if row is None: + return {"status": "error", "text": "Connecteur supprimé"} + if not row["enabled"]: + return {"status": "error", "text": "Connecteur désactivé"} + url = row["url"].rstrip("/") + if path: + url += "/" + path.lstrip("/") + code, text = await _get(url, _headers(int(ref))) + if code >= 400: + return {"status": "error", "text": f"HTTP {code} — {text[:400]}"} + return {"status": "ok", "text": text[:FETCH_LIMIT]} diff --git a/app/services/tool_registry.py b/app/services/tool_registry.py index 756f838..55a1a0c 100644 --- a/app/services/tool_registry.py +++ b/app/services/tool_registry.py @@ -1072,6 +1072,48 @@ class SearchCode(Tool): # ══════════════════════════ Registry ══════════════════════════ +class ConnectorFetch(Tool): + name = "connector_fetch" + description = ( + "Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), " + "recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). " + "`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; " + "`query` sert au connecteur web. Les hôtes privés sont refusés." + ) + parameters = { + "type": "object", + "properties": { + "connector": {"type": "string", + "description": "id, nom ou kind (gitea / github / web)"}, + "path": {"type": "string", "description": "chemin relatif (API natives)"}, + "query": {"type": "string", "description": "terme de recherche (web)"}, + }, + "required": ["connector"], + } + + async def execute(self, args, *, user_id=None) -> ToolResult: + from app.services import connectors + + try: + res = await connectors.connector_fetch( + str(args.get("connector") or ""), + str(args.get("path") or ""), + str(args.get("query") or ""), + ) + except ValueError as exc: + return ToolResult(status="error", tool=self.name, message=str(exc)) + except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run + logger.warning("connector_fetch failed: %s", exc) + return ToolResult(status="error", tool=self.name, + message=f"Connecteur indisponible: {exc}") + if res.get("status") != "ok": + return ToolResult(status="error", tool=self.name, + message=(res.get("text") or "erreur")[:500]) + text = res.get("text") or "" + return ToolResult(status="success", tool=self.name, target_type="connector", + message=text[:800], data={"text": text}) + + TOOL_CLASSES = [ SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument, CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument, @@ -1080,7 +1122,7 @@ TOOL_CLASSES = [ DeletePage, DeleteCollection, ReadGiteaIssues, SyncGitea, CreateGiteaIssue, DeleteDocument, - WebSearch, FetchUrl, SearchCode, + WebSearch, FetchUrl, SearchCode, ConnectorFetch, ] diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index d431295..b07c68f 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -444,6 +444,18 @@ body.fd-ap-resizing *{cursor:col-resize!important} +