fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+195 -1
View File
@@ -15,12 +15,14 @@ from __future__ import annotations
import json
import logging
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import UTC
from typing import Any
from app.db import get_conn
from app.services.collection_lifecycle import delete_collections
logger = logging.getLogger(__name__)
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
delete_collections(conn, [cid])
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
@@ -877,6 +879,197 @@ class CreateGiteaIssue(Tool):
)
# ══════════════════════════ Web tools ══════════════════════════
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
# re-vérifiée à chaque saut de redirection.
class WebSearch(Tool):
name = "web_search"
description = (
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "requête de recherche"},
"num_results": {"type": "integer",
"description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import available_providers, search_web
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
try:
num = max(1, min(int(args.get("num_results") or 5), 10))
except (TypeError, ValueError):
num = 5
try:
results, provider = await search_web(query, num)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche web indisponible: {exc}")
if not results:
hint = ""
if not available_providers().get(provider, True):
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
return ToolResult(status="error", tool=self.name,
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
return ToolResult(
status="success", tool=self.name, target_type="web_search",
data={"query": query, "provider": provider, "results": results,
"count": len(results)},
message=f"{len(results)} résultat(s) via {provider}",
)
class FetchUrl(Tool):
name = "fetch_url"
description = (
"Télécharge une page web et la convertit en Markdown (titres, listes, "
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
"web_search pour approfondir un résultat. Les URLs internes / privées "
"sont refusées."
)
parameters = {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL http(s) de la page"},
"max_chars": {"type": "integer",
"description": "longueur max du markdown renvoyé (défaut 20000)"},
},
"required": ["url"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.config import settings
from app.services.http_client import shared_client
from app.services.importers.html_notes import _html_to_markdown
from app.services.importers.url_fetch import _validate_url
from app.services.og_fetcher import parse_og
raw = str(args.get("url") or "").strip()
if not raw:
return ToolResult(status="error", tool=self.name, message="url vide")
# « example.com/page » → https://example.com/page, mais un schéma
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
raw = "https://" + raw
try:
url = _validate_url(raw)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
try:
max_chars = max(500, min(int(args.get("max_chars")
or settings.web_fetch_max_chars), 60000))
except (TypeError, ValueError):
max_chars = int(settings.web_fetch_max_chars or 20000)
current = url
resp = None
try:
async with shared_client(timeout=15.0, follow_redirects=False,
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
for _ in range(6):
resp = await client.get(current)
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("location")
if not loc:
raise ValueError("redirection sans cible")
from urllib.parse import urljoin
current = urljoin(current, loc)
# re-validation : une URL publique peut rediriger vers 169.254.x
current = _validate_url(current)
continue
resp.raise_for_status()
break
else:
raise ValueError("trop de redirections")
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Page injoignable: {exc}")
if resp is None:
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
ctype = (resp.headers.get("content-type") or "").lower()
body = resp.text
if "html" not in ctype and "xhtml" not in ctype:
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
markdown = body
meta: dict = {}
else:
meta = parse_og(body, current)
markdown = _html_to_markdown(body)
truncated = len(markdown) > max_chars
if truncated:
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
title = (meta.get("title") or "").strip() or current
return ToolResult(
status="success", tool=self.name, target_type="web_page", target_id=current,
data={"url": current, "title": title[:200], "markdown": markdown,
"truncated": truncated, "chars": len(markdown)},
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
)
class SearchCode(Tool):
name = "search_code"
description = (
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
"kind": {"type": "string",
"description": "repositories | code | issues (défaut repositories)"},
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services.web_search import search_github
query = str(args.get("query") or "").strip()
if not query:
return ToolResult(status="error", tool=self.name, message="query vide")
kind = str(args.get("kind") or "repositories")
try:
limit = max(1, min(int(args.get("limit") or 5), 10))
except (TypeError, ValueError):
limit = 5
try:
results = await search_github(query, kind, limit)
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name,
message=f"Recherche GitHub impossible: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="github",
target_id=f"{kind}:{query}",
data={"query": query, "kind": kind, "results": results, "count": len(results)},
message=f"{len(results)} résultat(s) GitHub ({kind})",
)
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
@@ -887,6 +1080,7 @@ TOOL_CLASSES = [
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
WebSearch, FetchUrl, SearchCode,
]