Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67bd252aa | ||
|
|
39d34ac866 | ||
|
|
efa3d57e93 | ||
|
|
992200a90a | ||
|
|
21b96afa12 | ||
|
|
16f73fe39e | ||
|
|
0f86294abc |
+114
@@ -1,5 +1,119 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
|
||||
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
|
||||
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
|
||||
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
|
||||
panneau (hamburger + ✕) plutôt qu'à la page.
|
||||
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
|
||||
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
|
||||
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
|
||||
titres, listes, gras et numéros survivent à l'insertion.
|
||||
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
|
||||
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
|
||||
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
|
||||
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
|
||||
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
|
||||
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
|
||||
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
|
||||
était avalée par cette branche) — aligné sur l'ordre déjà correct de
|
||||
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
|
||||
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
|
||||
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
|
||||
d'être ignorée.
|
||||
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
|
||||
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
|
||||
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
|
||||
|
||||
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mistral « Test connection » échouait malgré une clé valide** — cause
|
||||
racine : `mistral-large-latest` (modèle par défaut + tête de liste) n'est
|
||||
pas servi par les plans d'abonnement basiques → `403 tier_not_allowed`
|
||||
(code 1910). La clé elle-même fonctionne (`/v1/models` 200, chat 200 sur
|
||||
small/medium/nemo/codestral — vérifié en direct).
|
||||
- `PROVIDERS["mistral"]` default → `mistral-small-latest` (tous plans).
|
||||
- `PROVIDER_MODELS["mistral"]` réordonnée : modèles tous-plans d'abord
|
||||
(le test de connexion sélectionne le premier candidat).
|
||||
- `mistral` ajouté à `_CHAT_VALIDATED_PROVIDERS` : le fetch des modèles
|
||||
sonde chat/completions et exclut les modèles hors plan (46 listés →
|
||||
25 réellement utilisables avec la clé du compte).
|
||||
- **Migration 36** : `default_model` des lignes mistral pointant sur un
|
||||
modèle bloqué réinitialisé (→ nouveau défaut), `llm_config.model` idem.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_agent.py::test_mistral_defaults_are_tier_safe` · suite agent
|
||||
OK · test de connexion live sur l'instance déployée : `{"ok":true,
|
||||
"model":"mistral-small-latest","reply":"PONG","verified":true}`.
|
||||
|
||||
## v7.59.0 (2026-10-07) — Mobile : drawer réglable, side-nav settings, aide refondue
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Scroll souris du drawer sidebar mobile** — `display:block` du breakpoint
|
||||
≤768px cassait le `flex:1` de `.sidebar-scroll` (hauteur = contenu, jamais
|
||||
de zone scrollable). Remis en `display:flex;flex-direction:column`.
|
||||
- **Rate limiter du routeur de test** — fenêtre remise à zéro à chaque login
|
||||
: suite e2e via un seul login `beforeAll` + cookies `storageState`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Settings mobile : le menu devient une side-navigation gauche** ouverte
|
||||
par un hamburger (`.settings-menu-btn`) avec backdrop, au lieu des onglets
|
||||
horizontaux ; fermeture auto au choix de section. Squelette panneau
|
||||
(overlay/panel/nav) extrait de `settings.html` vers
|
||||
`/static/css/settings.css` partagé avec l'aide.
|
||||
- **Page `/help` refondue au complet** — ancien monologue HTML inline de la
|
||||
route remplacé par `app/templates/help.html` : même panneau 2 colonnes que
|
||||
settings, nav latérale en 13 sections couvrant toute l'app (démarrer,
|
||||
éditeur, bases & vues, tâches/dépendances, workspaces/forges, collaboration,
|
||||
bibliothèque/recherche, agent IA & automations, API v2/webhooks/clipper,
|
||||
PWA offline, comptes & SSO, raccourcis, tips) ; `static/js/help.js`
|
||||
(`Alpine.data helpInit`).
|
||||
|
||||
### Tests
|
||||
|
||||
- Gate e2e `mobile_regression.spec.js` : 6/6 (drawer + wheel scroll +
|
||||
side-nav settings + aide mobile/desktop) · `test_app.py` +
|
||||
`test_pwa_offline.py` : 230/230.
|
||||
|
||||
## v7.58.0 (2026-10-06) — Add plugins : catalogue on/off à effet réel (phase 8/8)
|
||||
|
||||
### Added
|
||||
|
||||
- **`app/services/plugins.py` + migration 35** — table `plugins` (`slug`,
|
||||
`name`, `description`, `enabled`) pré-remplie avec **3 modules câblés** :
|
||||
`web-tools`, `web-clipper`, `automations`. Ligne absente = activé (défaut
|
||||
sûr : rien n'est coupé par accident).
|
||||
- **Effet réel, jamais un simple drapeau** :
|
||||
- `automations` OFF → dépendance FastAPI posée **à l'`include_router`**
|
||||
(3 lignes dans `main.py`, aucun router touché) → toutes les routes
|
||||
`/workspace/automations*` refusées, **et** le scheduler de fond passe en
|
||||
veille (garde sur chaque tick) ;
|
||||
- `web-clipper` OFF → `GET /extensions` + tout `/api/v2/web-clipper/*`
|
||||
refusés ;
|
||||
- `web-tools` OFF → `web_search` et `fetch_url` retirés du **schéma** de
|
||||
l'agent et de `execute()` (`ToolRegistry._all()`) : le LLM ne les voit plus.
|
||||
- **UI masquée côté serveur** — nouveau global Jinja `plugin_enabled(slug)` :
|
||||
`settings.html` rend la nav « Extensions » / « Automations » sous
|
||||
`{% if %}` (élément **absent** du DOM, pas seulement caché) et conditionne
|
||||
les sections en `x-show`.
|
||||
- **Menu +** — l'entrée « Add plugins » devient une vraie section : liste des
|
||||
3 plugins (état + description) avec bascule ;
|
||||
`GET /api/agent/plugins` + `PATCH /api/agent/plugins/{slug}` (slug inconnu →
|
||||
404, 401 sans session).
|
||||
- **Tests** — `tests/test_v758_plugins.py` : **10 tests** — catalogue,
|
||||
persistance en base, routes refusées (302 hors `/api` + 404 JSON pour
|
||||
`/api*`, handler unifié de `main.py`), outils retirés du registre, nav
|
||||
disparue du `/settings` rendu, câblage du menu, 401.
|
||||
|
||||
## v7.57.0 (2026-10-06) — Connecteurs : Discord, Telegram, Teams, MCP (phase 7/8)
|
||||
|
||||
### Added
|
||||
|
||||
+26
-9
@@ -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-7 ✅ 2026-10-06 · phase 8 planifiée)
|
||||
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)
|
||||
|
||||
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||
> un menu à sections, tel que demandé :
|
||||
@@ -496,18 +496,35 @@ CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
|
||||
CHANGELOG, ce ROADMAP
|
||||
|
||||
### Phase 8 — v7.58.0 — Add plugins · effort M
|
||||
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
|
||||
|
||||
- [ ] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) — état persisté
|
||||
- [ ] **Catalogue dans le menu +** — bascule on/off à effet **réel** : un plugin
|
||||
désactivé retire vraiment sa route/UI (au minimum 3 plugins câblés : web tools de
|
||||
l'agent, web clipper, automations)
|
||||
- [ ] **Tests** — plugin OFF = route refusée + UI absente (pas seulement un drapeau lu)
|
||||
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
|
||||
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
|
||||
activé (défaut sûr)
|
||||
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
|
||||
(fini le `disabled:true`) : liste des 3 plugins + bascule via
|
||||
`GET/PATCH /api/agent/plugins[/slug]`
|
||||
- [x] **Bascule on/off à effet réel** :
|
||||
- `automations` → **toutes** les routes `/workspace/automations*` refusées
|
||||
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
|
||||
scheduler de fond en veille (garde par tick) ;
|
||||
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
|
||||
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
|
||||
`execute()` (`ToolRegistry._all()`) ;
|
||||
- UI → nav « Extensions » / « Automations » rendues sous
|
||||
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
|
||||
sections conditionnées en `x-show`
|
||||
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
|
||||
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
|
||||
retiré, persistance, 401 sans session
|
||||
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
|
||||
(525 chemins), CHANGELOG, ce ROADMAP
|
||||
|
||||
---
|
||||
|
||||
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 7 livrées le
|
||||
2026-10-06 (v7.51.0 → v7.57.0)**, phase 8 (plugins) à valider avant « go ».
|
||||
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
|
||||
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
|
||||
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
|
||||
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||
|
||||
---
|
||||
|
||||
+11
-5
@@ -5,7 +5,7 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
@@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.routers.webauthn import router as webauthn_router
|
||||
from app.routers.wiki import router as wiki_router
|
||||
from app.routers.workers import router as workers_router
|
||||
from app.services import plugins as plugins_service
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.57.0",
|
||||
version="7.59.2",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -235,7 +236,9 @@ app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
# Plugin « automations » OFF → chaque route de ce router renvoie 404
|
||||
app.include_router(automations_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("automations"))])
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
@@ -247,8 +250,11 @@ app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
|
||||
app.include_router(web_clipper_api_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||
app.include_router(web_clipper_router,
|
||||
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||
app.include_router(api_v2_router)
|
||||
app.include_router(api_v2_agent_router)
|
||||
app.include_router(sites_router)
|
||||
|
||||
@@ -1586,6 +1586,30 @@ def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
|
||||
def _migration_plugins(conn: sqlite3.Connection) -> None:
|
||||
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS plugins (
|
||||
slug TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
)"""
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
|
||||
[
|
||||
("web-tools", "Outils web de l'agent",
|
||||
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||
("web-clipper", "Web Clipper",
|
||||
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||
("automations", "Automations",
|
||||
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
|
||||
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
|
||||
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
|
||||
@@ -1654,3 +1678,24 @@ def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
|
||||
|
||||
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
|
||||
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
||||
"""mistral-large-latest / pixtral-large-latest renvoient 403
|
||||
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
|
||||
valide mais le test de connexion et l'agent échouaient. Reset du
|
||||
default_model stocké → vide = nouveau défaut du provider (small-latest,
|
||||
servi par tous les plans).
|
||||
"""
|
||||
blocked = ("mistral-large-latest", "pixtral-large-latest")
|
||||
conn.execute(
|
||||
"UPDATE user_llm_keys SET default_model='' "
|
||||
"WHERE provider='mistral' AND default_model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE llm_config SET model='mistral-small-latest' "
|
||||
"WHERE provider='mistral' AND model IN (?,?)",
|
||||
blocked,
|
||||
)
|
||||
|
||||
+18
-1
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import connectors, oauth_connectors, skill_gallery
|
||||
from app.services import connectors, oauth_connectors, plugins, 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 (
|
||||
@@ -647,6 +647,23 @@ def list_connectors_route(request: Request):
|
||||
return {"connectors": connectors.list_connectors(user_id)}
|
||||
|
||||
|
||||
@router.get("/plugins")
|
||||
def list_plugins_route(request: Request):
|
||||
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
|
||||
_current_user_id(request)
|
||||
return {"plugins": plugins.list_plugins()}
|
||||
|
||||
|
||||
@router.patch("/plugins/{slug}")
|
||||
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
|
||||
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
|
||||
_current_user_id(request)
|
||||
try:
|
||||
return plugins.set_enabled(slug, bool(body.get("enabled")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/connectors")
|
||||
def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
|
||||
|
||||
@@ -251,209 +251,10 @@ def accounts_page(request: Request):
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
"""Help & documentation page — settings-style panel with side navigation."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
return HTMLResponse(block_tpl.render(
|
||||
**sidebar,
|
||||
request=request,
|
||||
page_title="Help",
|
||||
title_prefix="Help",
|
||||
page_icon="❓",
|
||||
content_html="""<style>
|
||||
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
||||
.help-hero{text-align:center;margin-bottom:48px;}
|
||||
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
||||
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
||||
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
||||
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
||||
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
||||
.help-card .icon{font-size:20px;}
|
||||
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
||||
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
||||
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
||||
.help-card li::before{content:'• ';color:var(--accent);}
|
||||
.help-section{margin-bottom:48px;}
|
||||
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
<div class="help-page">
|
||||
<div class="help-hero">
|
||||
<h1>❓ FlowDeck Help</h1>
|
||||
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-grid">
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🚀</span>Getting Started</h3>
|
||||
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
||||
<ul>
|
||||
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
||||
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
||||
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
||||
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
||||
<ul>
|
||||
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
||||
<li>Drag & drop pages in the sidebar to reorganize</li>
|
||||
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
||||
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
||||
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
||||
<ul>
|
||||
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
||||
<li>Browse repo file trees in the sidebar</li>
|
||||
<li>Create & edit files with commit messages</li>
|
||||
<li>Sync labels as tags</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
||||
<p>Share pages with collaborators or publish them to the web.</p>
|
||||
<ul>
|
||||
<li>Click <b>Share</b> in the page editor top-right</li>
|
||||
<li>Share with specific users or get a public link</li>
|
||||
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
||||
<p>Find all your content in one place with powerful filtering.</p>
|
||||
<ul>
|
||||
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
||||
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
||||
<ul>
|
||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
||||
<li>Edits made offline are queued locally and synced automatically</li>
|
||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>⌨️ Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔐 Authentication</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck supports three authentication methods:<br>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
||||
</p>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>📶 Offline mode (PWA)</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
||||
page. If a page with the same title already exists, the offline copy is renamed
|
||||
<i>“Title (copie offline)”</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
||||
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
||||
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
||||
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
||||
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>"""
|
||||
))
|
||||
|
||||
|
||||
return ENV.get_template("help.html").render(**sidebar, request=request)
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
|
||||
@@ -27,7 +27,7 @@ import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services import notifications, plugins
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -479,6 +479,9 @@ async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
|
||||
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
||||
# mistral-small est servi par tous les plans d'abonnement ;
|
||||
# mistral-large → 403 tier_not_allowed sur les plans basiques.
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
|
||||
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
||||
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
|
||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
||||
# Ordre = ordre de sélection du test de connexion : les modèles servis par
|
||||
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
|
||||
# basique), les modèles à plan élevé en fin de liste.
|
||||
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
|
||||
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
|
||||
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
||||
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
||||
|
||||
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# chat completions — the exact failure the user hit.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
||||
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
|
||||
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
|
||||
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
|
||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
|
||||
|
||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||
_NON_CHAT_MARKERS = (
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
|
||||
|
||||
Chaque plugin câble une chose concrète, jamais un simple drapeau :
|
||||
|
||||
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
|
||||
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
|
||||
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
|
||||
|
||||
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
|
||||
(``main.py``) : 3 lignes, aucun fichier de router touché.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
# slug → (nom affiché, description affichée)
|
||||
CATALOG: list[tuple[str, str, str]] = [
|
||||
("web-tools", "Outils web de l'agent",
|
||||
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||
("web-clipper", "Web Clipper",
|
||||
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||
("automations", "Automations",
|
||||
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||
]
|
||||
|
||||
# slug → outils LLM retirés du registre quand le plugin est OFF
|
||||
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
|
||||
|
||||
|
||||
def is_enabled(slug: str) -> bool:
|
||||
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
|
||||
return True if row is None else bool(row["enabled"])
|
||||
|
||||
|
||||
def list_plugins() -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = {r["slug"]: bool(r["enabled"])
|
||||
for r in conn.execute("SELECT slug, enabled FROM plugins")}
|
||||
return [{"slug": slug, "name": name, "description": desc,
|
||||
"enabled": rows.get(slug, True)}
|
||||
for slug, name, desc in CATALOG]
|
||||
|
||||
|
||||
def set_enabled(slug: str, enabled: bool) -> dict:
|
||||
if not any(s == slug for s, _, _ in CATALOG):
|
||||
raise ValueError(f"Plugin inconnu: {slug}")
|
||||
name = next(n for s, n, _ in CATALOG if s == slug)
|
||||
desc = next(d for s, _, d in CATALOG if s == slug)
|
||||
with get_conn() as conn:
|
||||
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
|
||||
conn.execute(
|
||||
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
|
||||
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
|
||||
(slug, name, desc, 1 if enabled else 0))
|
||||
conn.commit()
|
||||
return next(p for p in list_plugins() if p["slug"] == slug)
|
||||
|
||||
|
||||
def plugin_required(slug: str):
|
||||
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
|
||||
|
||||
`Request` doit être importé au **module** : les annotations sont des
|
||||
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
|
||||
les globales du module — sinon il lit un query param → 422.
|
||||
"""
|
||||
|
||||
def dep(request: Request) -> None:
|
||||
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
|
||||
if not is_enabled(slug):
|
||||
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
|
||||
|
||||
return dep
|
||||
@@ -1172,10 +1172,16 @@ class ToolRegistry:
|
||||
tools = dict(self.tools)
|
||||
try:
|
||||
from app.services import mcp_client
|
||||
from app.services import plugins as plugins_service
|
||||
for entry in mcp_client.cached_tools():
|
||||
tools[entry["name"]] = McpTool(entry)
|
||||
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
|
||||
for slug, names in plugins_service.PLUGIN_TOOLS.items():
|
||||
if not plugins_service.is_enabled(slug):
|
||||
for name in names:
|
||||
tools.pop(name, None)
|
||||
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
||||
logger.exception("MCP dynamic tools load failed")
|
||||
logger.exception("dynamic tools load failed")
|
||||
return tools
|
||||
|
||||
def list(self, scope: dict | None = None) -> list[str]:
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
|
||||
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
|
||||
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
|
||||
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||
@@ -1944,6 +1945,17 @@
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
// Hamburger: mobile opens the drawer (assignment expressions are illegal
|
||||
// in the Alpine CSP build, hence this method), desktop toggles collapse.
|
||||
fdHamburgerClick() {
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
this.mobileSidebarOpen = true;
|
||||
this.sidebarCollapsed = false;
|
||||
} else {
|
||||
this.toggleSidebar();
|
||||
}
|
||||
},
|
||||
|
||||
closeSidebar() {
|
||||
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
||||
this.sidebarCollapsed = true;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Help{% endblock %}
|
||||
{% block title_prefix %}Help{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "help-circle" %}
|
||||
{% set page_title = "Help" %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
|
||||
<style>
|
||||
.help-body{margin-bottom:24px;max-width:760px;}
|
||||
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
|
||||
.help-body ul{margin:0 0 14px;padding-left:18px;}
|
||||
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
|
||||
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
|
||||
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
|
||||
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
|
||||
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
|
||||
|
||||
<!-- Mobile drawer backdrop -->
|
||||
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||
<div class="settings-nav-header">Basics</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages & Editor</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases & Views</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks & Dependencies</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces & Forges</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash & Search</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent & Automations</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations & API</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline & PWA</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts & SSO</div>
|
||||
<div class="settings-nav-header">Reference</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
|
||||
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="settings-content">
|
||||
|
||||
<div class="help-body" x-show="section==='start'">
|
||||
<h2>Getting Started</h2>
|
||||
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
|
||||
<h3>First steps</h3>
|
||||
<ul>
|
||||
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
|
||||
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
|
||||
<li>Use the <b>Agent & IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
|
||||
</ul>
|
||||
<h3>The sidebar</h3>
|
||||
<ul>
|
||||
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
|
||||
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
|
||||
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='editor'">
|
||||
<h2>Pages & Editor</h2>
|
||||
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
|
||||
<h3>Blocks</h3>
|
||||
<ul>
|
||||
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
|
||||
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>> </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
|
||||
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
|
||||
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
|
||||
</ul>
|
||||
<h3>Page features</h3>
|
||||
<ul>
|
||||
<li>Cover image and icon on every page (click the top of the page).</li>
|
||||
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
|
||||
<li>Comments (threaded), page history with snapshots, backlinks.</li>
|
||||
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='databases'">
|
||||
<h2>Databases & Views</h2>
|
||||
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
|
||||
<h3>Views</h3>
|
||||
<ul>
|
||||
<li><b>Table</b> — sortable columns, inline editing.</li>
|
||||
<li><b>Board (Kanban)</b> — group by select/status, drag & drop between columns.</li>
|
||||
<li><b>Calendar</b> — month grid by date property.</li>
|
||||
<li><b>Gallery</b> — visual cards, configurable card size.</li>
|
||||
<li><b>List</b> — compact rows with preview.</li>
|
||||
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
|
||||
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
|
||||
</ul>
|
||||
<h3>Relations, rollups, formulas</h3>
|
||||
<ul>
|
||||
<li>Relations are bidirectional: link rows across collections.</li>
|
||||
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
|
||||
<li>Formulas support 19 functions over row properties.</li>
|
||||
<li>Filters, sorts and grouping apply per-view.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='tasks'">
|
||||
<h2>Tasks & Dependencies</h2>
|
||||
<ul>
|
||||
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
|
||||
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
|
||||
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
|
||||
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='workspaces'">
|
||||
<h2>Workspaces & Forges</h2>
|
||||
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
|
||||
<ul>
|
||||
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
|
||||
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
|
||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
|
||||
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
|
||||
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='collab'">
|
||||
<h2>Collaboration</h2>
|
||||
<ul>
|
||||
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
|
||||
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
|
||||
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
|
||||
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
|
||||
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
|
||||
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
|
||||
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='nav'">
|
||||
<h2>Library, Trash & Search</h2>
|
||||
<ul>
|
||||
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
|
||||
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
|
||||
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
|
||||
<li>Full-text search API: <code>/api/v2/search</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='agent'">
|
||||
<h2>AI Agent & Automations</h2>
|
||||
<ul>
|
||||
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
|
||||
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
|
||||
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
|
||||
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
|
||||
<li>Configure providers in <b>Settings → Agent & IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='integrations'">
|
||||
<h2>Integrations & API</h2>
|
||||
<ul>
|
||||
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
|
||||
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
|
||||
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
|
||||
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
|
||||
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='pwa'">
|
||||
<h2>Offline mode (PWA)</h2>
|
||||
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
|
||||
<ul>
|
||||
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
|
||||
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
|
||||
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
|
||||
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='auth'">
|
||||
<h2>Accounts & SSO</h2>
|
||||
<p>
|
||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
|
||||
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
|
||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
|
||||
</p>
|
||||
<ul>
|
||||
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
|
||||
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
|
||||
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
|
||||
</ul>
|
||||
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='shortcuts'">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
|
||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||
</div>
|
||||
|
||||
<div class="help-body" x-show="section==='tips'">
|
||||
<h2>Tips</h2>
|
||||
<ul>
|
||||
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
|
||||
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
|
||||
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
|
||||
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
|
||||
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
|
||||
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
+15
-21
@@ -11,16 +11,6 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||||
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||
.setting-group{margin-bottom:28px;}
|
||||
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||
@@ -69,9 +59,7 @@
|
||||
.status-dot.active{background:var(--toast-success-bg);}
|
||||
.status-dot.inactive{background:var(--danger);}
|
||||
|
||||
/* Close button */
|
||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||
/* Close button — shared styles in /static/css/settings.css */
|
||||
|
||||
/* Agent & IA — status summary + provider cards */
|
||||
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
|
||||
@@ -125,26 +113,32 @@
|
||||
.llm-detail .prov-field-row{margin-top:12px;}
|
||||
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
|
||||
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<div class="settings-overlay" x-data="settingsInit()">
|
||||
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
|
||||
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav">
|
||||
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
|
||||
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||
<div class="settings-nav-header">Account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
<div class="settings-nav-header">Features</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
|
||||
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -423,7 +417,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} && activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
@@ -584,7 +578,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Automations -->
|
||||
<div x-show="activeSection==='automations'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} && activeSection==='automations'">
|
||||
<h2>Automations</h2>
|
||||
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||||
|
||||
|
||||
@@ -77,3 +77,11 @@ except OSError: # pragma: no cover
|
||||
ENV.globals["asset_version"] = ASSET_VERSION
|
||||
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
||||
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
|
||||
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
from app.services.plugins import is_enabled
|
||||
|
||||
return is_enabled(slug)
|
||||
|
||||
@@ -223,6 +223,28 @@ Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, RO
|
||||
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
|
||||
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
|
||||
|
||||
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
|
||||
|
||||
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
|
||||
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
|
||||
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
|
||||
`from __future__ import annotations` les annotations sont des chaînes et
|
||||
FastAPI les résout dans les globales du module — sinon il lit un query param
|
||||
→ 422 (piège réel, corrigé en cours de phase).
|
||||
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
|
||||
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
|
||||
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
|
||||
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
|
||||
sections ne sont pas restructurées, leur booléen est rendu au serveur.
|
||||
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
|
||||
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
|
||||
les tests assertent les deux formes au lieu de réécrire le handler.
|
||||
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
|
||||
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
|
||||
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
|
||||
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
|
||||
son effet n'est pas déjà couvert).
|
||||
|
||||
## 10. Hors périmètre
|
||||
|
||||
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
|
||||
|
||||
+74
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.57.0"
|
||||
"version": "7.59.2"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -1098,7 +1098,7 @@
|
||||
"dashboard"
|
||||
],
|
||||
"summary": "Help Page",
|
||||
"description": "Comprehensive help & documentation page.",
|
||||
"description": "Help & documentation page — settings-style panel with side navigation.",
|
||||
"operationId": "help_page_help_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -16299,6 +16299,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/plugins": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "List Plugins Route",
|
||||
"description": "Catalogue des plugins de l'instance (menu + → « Add plugins »).",
|
||||
"operationId": "list_plugins_route_api_agent_plugins_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/plugins/{slug}": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
"agent"
|
||||
],
|
||||
"summary": "Set Plugin Route",
|
||||
"description": "Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau.",
|
||||
"operationId": "set_plugin_route_api_agent_plugins__slug__patch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "slug",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Slug"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/agent/connectors/{connector_id}": {
|
||||
"patch": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
const USER = process.env.FD_USER || '[email protected]';
|
||||
const PASS = process.env.FD_PASS || 'e2e-pass-123';
|
||||
|
||||
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
|
||||
// as storageState by every test.
|
||||
let STATE = null;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
|
||||
if (!ok) {
|
||||
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
|
||||
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', USER);
|
||||
await page.fill('#password', PASS);
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 10000 });
|
||||
}
|
||||
STATE = await ctx.storageState();
|
||||
await ctx.close();
|
||||
});
|
||||
async function login(page) {
|
||||
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
|
||||
await page.context().addCookies(STATE.cookies);
|
||||
}
|
||||
|
||||
test.describe('mobile', () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message));
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const burger = page.locator('.hamburger-btn');
|
||||
await expect(burger).toBeVisible();
|
||||
|
||||
// Sidebar starts off-canvas
|
||||
const sidebar = page.locator('#sidebar');
|
||||
const boxBefore = await sidebar.boundingBox();
|
||||
expect(boxBefore.x).toBeLessThan(0);
|
||||
|
||||
await burger.click();
|
||||
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||
const boxAfter = await sidebar.boundingBox();
|
||||
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
|
||||
expect(errs).toEqual([]);
|
||||
|
||||
// Overlay tap closes it
|
||||
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
|
||||
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.hamburger-btn').click();
|
||||
await page.waitForSelector('#sidebar.mobile-open');
|
||||
const sc = page.locator('.sidebar-scroll');
|
||||
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
|
||||
const box = await sc.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('settings page fits mobile viewport', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
const panel = page.locator('.settings-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
const box = await panel.boundingBox();
|
||||
expect(box.width).toBeLessThanOrEqual(390);
|
||||
expect(box.height).toBeLessThanOrEqual(844);
|
||||
|
||||
// No horizontal page overflow
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
const burger = page.locator('.settings-menu-btn');
|
||||
await expect(burger).toBeVisible();
|
||||
const nav = page.locator('.settings-nav');
|
||||
const before = await nav.boundingBox();
|
||||
expect(before.x + before.width).toBeLessThanOrEqual(1);
|
||||
|
||||
await burger.click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
|
||||
// Nav item switches section AND closes the drawer
|
||||
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
|
||||
|
||||
// Re-open and dismiss via backdrop
|
||||
await burger.click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
});
|
||||
|
||||
test('help page uses settings-style side navigation', async ({ page }) => {
|
||||
const errs = [];
|
||||
page.on('pageerror', (e) => errs.push(e.message));
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Same panel skeleton as settings
|
||||
await expect(page.locator('.settings-panel')).toBeVisible();
|
||||
await expect(page.locator('.settings-menu-btn')).toBeVisible();
|
||||
|
||||
// Mobile: nav drawer behind hamburger
|
||||
const nav = page.locator('.settings-nav');
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-menu-btn').click();
|
||||
await expect(nav).toHaveClass(/nav-open/);
|
||||
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
|
||||
await expect(nav).not.toHaveClass(/nav-open/);
|
||||
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
|
||||
|
||||
// App-wide coverage: all sections exist in the nav
|
||||
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
|
||||
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
|
||||
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
|
||||
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
|
||||
for (const s of sections) expect(labels, s).toContain(s);
|
||||
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('desktop', () => {
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||
const nav = page.locator('.settings-nav');
|
||||
await expect(nav).toBeVisible();
|
||||
const navBox = await nav.boundingBox();
|
||||
expect(navBox.height).toBeGreaterThan(navBox.width);
|
||||
|
||||
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
|
||||
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
|
||||
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const { test } = require('@playwright/test');
|
||||
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
|
||||
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
||||
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
|
||||
await page.click('.btn-primary');
|
||||
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
|
||||
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
|
||||
await page.locator('.hamburger-btn').click();
|
||||
await page.waitForSelector('#sidebar.mobile-open');
|
||||
|
||||
const info = await page.evaluate(() => {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const sc = document.querySelector('.sidebar-scroll');
|
||||
const cs = getComputedStyle(sb);
|
||||
const ccs = sc ? getComputedStyle(sc) : null;
|
||||
return {
|
||||
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
|
||||
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
|
||||
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
|
||||
};
|
||||
});
|
||||
console.log('DIAG:', JSON.stringify(info, null, 1));
|
||||
|
||||
await page.mouse.move(150, 400);
|
||||
await page.mouse.wheel(0, 300);
|
||||
await page.waitForTimeout(300);
|
||||
const after = await page.evaluate(() => {
|
||||
const sc = document.querySelector('.sidebar-scroll');
|
||||
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
|
||||
});
|
||||
console.log('AFTER WHEEL:', JSON.stringify(after));
|
||||
});
|
||||
+12
-3
@@ -2476,11 +2476,20 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Numbered list */
|
||||
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
|
||||
.block-numbered {
|
||||
padding-left: 22px;
|
||||
padding-left: 32px;
|
||||
position: relative;
|
||||
}
|
||||
.block-numbered[data-num]::before {
|
||||
content: attr(data-num) ". ";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
min-width: 24px;
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* To-do */
|
||||
.block-todo {
|
||||
@@ -3424,7 +3433,7 @@ button.breadcrumb-current { max-width: 260px; }
|
||||
|
||||
/* ── Sidebar mobile ── */
|
||||
.sidebar {
|
||||
display: block;
|
||||
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
|
||||
position: fixed !important;
|
||||
top: 0; left: 0;
|
||||
bottom: 0; width: 85vw; max-width: 320px;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
|
||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
||||
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;text-decoration:none;}
|
||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
||||
|
||||
/* Mobile hamburger for the panel nav — hidden on desktop */
|
||||
.settings-menu-btn{display:none;position:absolute;top:8px;left:8px;z-index:20;width:40px;height:40px;border:none;border-radius:8px;background:var(--bg-tertiary);color:var(--text);align-items:center;justify-content:center;cursor:pointer;}
|
||||
.settings-nav-backdrop{display:none;}
|
||||
|
||||
/* ── Mobile: nav becomes a left side-navigation drawer (hamburger opens it) ── */
|
||||
@media (max-width:768px){
|
||||
.settings-overlay{background:var(--bg-primary);backdrop-filter:none;}
|
||||
.settings-panel{flex-direction:column;width:100vw;max-width:100vw;height:100dvh;max-height:100dvh;margin:0;border:none;border-radius:0;box-shadow:none;}
|
||||
.settings-menu-btn{display:flex;}
|
||||
.settings-nav{position:fixed;top:0;left:0;bottom:0;width:260px;max-width:80vw;min-width:0;z-index:600;transform:translateX(-100%);transition:transform .2s cubic-bezier(0.4,0,0.2,1);border-right:1px solid var(--border);box-shadow:4px 0 24px rgba(0,0,0,.4);padding-top:8px;}
|
||||
.settings-nav.nav-open{transform:translateX(0);}
|
||||
.settings-nav-item{padding:10px 16px;}
|
||||
.settings-nav-backdrop{display:block;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:599;}
|
||||
.settings-content{padding:56px 16px 16px;}
|
||||
.settings-close{top:8px;right:8px;width:44px;height:44px;background:var(--bg-tertiary);}
|
||||
.setting-row{flex-wrap:wrap;}
|
||||
.setting-control{margin-left:0;width:100%;}
|
||||
.prov-grid{grid-template-columns:1fr;}
|
||||
.avatar-section{gap:10px;}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
{key:'canvases', icon:'🎨', label:'Design System – Canevas',
|
||||
sub:'Galerie de canevas : créer, insérer, enregistrer', action:'canvases', chev:true},
|
||||
{key:'plugins', icon:'🧩', label:'Add plugins',
|
||||
sub:'Modules on/off — bientôt (phase 8)', disabled:true},
|
||||
sub:'Modules on/off — effet réel : routes, UI, outils', action:'plugins', chev:true},
|
||||
{key:'memory', icon:'🧠', label:'Mémoire (on/off)',
|
||||
sub:'Résumés des échanges injectés à chaque run', action:'memory'}
|
||||
];
|
||||
@@ -74,7 +74,7 @@
|
||||
skillForm: {id: null, name: '', description: '', prompt_template: ''},
|
||||
galleryFilter: '',
|
||||
canvases: [], canvas: null,
|
||||
connectors: [], connector: null,
|
||||
connectors: [], connector: null, plugins: [],
|
||||
connectorForm: {name: '', url: '', secret: '', kind: 'custom',
|
||||
auth: 'bearer', hint: ''},
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
@@ -1148,6 +1148,7 @@
|
||||
if(s === 'connector-new') return 'Ajouter un connecteur';
|
||||
if(s === 'canvases') return 'Design System – Canevas';
|
||||
if(s === 'canvas') return this.canvas ? this.canvas.name : 'Canevas';
|
||||
if(s === 'plugins') return 'Add plugins';
|
||||
return 'Ajouter au contexte';
|
||||
},
|
||||
get plusItems(){
|
||||
@@ -1163,6 +1164,7 @@
|
||||
if(s === 'connector-new') return [];
|
||||
if(s === 'canvases') return this._canvasItems();
|
||||
if(s === 'canvas') return this._canvasActionItems();
|
||||
if(s === 'plugins') return this._pluginItems();
|
||||
if(s === 'root'){
|
||||
// l'entrée Mémoire reflète l'état réel du toggle de la conversation
|
||||
var on = this.memoryOn;
|
||||
@@ -1274,6 +1276,39 @@
|
||||
var tools = (c.kind === 'mcp' && c.tools_count) ? ' · ' + c.tools_count + ' outil(s)' : '';
|
||||
return mark + (c.detail || c.status) + tools + (c.enabled ? '' : ' · désactivé');
|
||||
},
|
||||
// ── Plugins de l'instance (v7.58.0) ──
|
||||
fetchPlugins(){
|
||||
var self = this;
|
||||
fetch('/api/agent/plugins').then(function(r){ return r.json(); })
|
||||
.then(function(d){ self.plugins = (d && d.plugins) || []; })
|
||||
.catch(function(){ self.plugins = []; });
|
||||
},
|
||||
_pluginItems(){
|
||||
var out = (this.plugins || []).map(function(pl){
|
||||
return {key:'pl-' + pl.slug, icon: pl.enabled ? '✅' : '⛔',
|
||||
label: pl.name,
|
||||
sub: (pl.enabled ? 'Activé' : 'Désactivé') + ' — ' + pl.description,
|
||||
action:'plugin-toggle', slug: pl.slug};
|
||||
});
|
||||
if(!out.length) out.push({key:'pl-none', _sep:'Aucun plugin'});
|
||||
out.push({key:'pl-hint', _sep:
|
||||
'OFF = routes refusées, UI masquée, outils retirés'});
|
||||
return out;
|
||||
},
|
||||
pluginToggle(slug){
|
||||
var self = this;
|
||||
var pl = (this.plugins || []).filter(function(x){ return x.slug === slug; })[0];
|
||||
if(!pl) return;
|
||||
fetch('/api/agent/plugins/' + encodeURIComponent(slug), {
|
||||
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body: JSON.stringify({enabled: !pl.enabled})
|
||||
}).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); })
|
||||
.then(function(res){
|
||||
if(!res.ok){ self.toast((res.d && res.d.detail) || 'Bascule impossible.', true); return; }
|
||||
self.toast(res.d.enabled ? 'Plugin activé.' : 'Plugin désactivé.');
|
||||
self.fetchPlugins();
|
||||
}).catch(function(){ self.toast('Bascule impossible (réseau).', true); });
|
||||
},
|
||||
_connectorItems(){
|
||||
var self = this;
|
||||
var out = (this.connectors || []).map(function(c){
|
||||
@@ -1579,6 +1614,10 @@
|
||||
auth:'bearer', hint:''};
|
||||
this.plusSection = 'connector-new'; this.plusFocus = -1; return;
|
||||
}
|
||||
if(it.action === 'plugins'){
|
||||
this.fetchPlugins(); this.plusSection = 'plugins'; this.plusFocus = 0; return;
|
||||
}
|
||||
if(it.action === 'plugin-toggle'){ this.pluginToggle(it.slug); return; }
|
||||
if(it.action === 'connector-probe'){ this.connectorProbe(); return; }
|
||||
if(it.action === 'connector-toggle'){ this.connectorToggle(); return; }
|
||||
if(it.action === 'connector-delete'){ this.connectorDelete(); return; }
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
if (!window.__fdHelpScriptLoaded) {
|
||||
window.__fdHelpScriptLoaded = true;
|
||||
// Alpine.data register (CSP build ne résout pas les globales window)
|
||||
if (window.Alpine) { Alpine.data('helpInit', helpInit); fdRefreshXData('helpInit'); } else document.addEventListener('alpine:init', function () { Alpine.data('helpInit', helpInit); });
|
||||
function helpInit() {
|
||||
return {
|
||||
section: 'start',
|
||||
navOpen: false,
|
||||
closeNavOnMobile() { this.navOpen = false; },
|
||||
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
|
||||
close() {
|
||||
if (document.referrer && document.referrer !== location.href) { history.back(); }
|
||||
else { location.href = '/workspaces'; }
|
||||
},
|
||||
go(sec) {
|
||||
this.section = sec;
|
||||
this.navOpen = false;
|
||||
var c = document.querySelector('.settings-content');
|
||||
if (c) c.scrollTop = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -419,7 +419,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
|
||||
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
|
||||
// → framed result → "Insert below" → applyAIBlocks.
|
||||
const AIC={_open:false,_idx:-1,_stage:'',_html:'',
|
||||
const AIC={_open:false,_idx:-1,_stage:'',_html:'',_md:'',
|
||||
ROOT:null,
|
||||
mk(){ if(this.ROOT) return this.ROOT;
|
||||
const d=document.createElement('div'); d.id='_aiC'; d.style.cssText='position:fixed;z-index:1200;width:420px;max-width:92vw;display:none';
|
||||
@@ -432,7 +432,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
this.paint(); this.ROOT.style.display='block'; this._open=true; this._focusIngest();
|
||||
document.addEventListener('mousedown',function onDoc(ev){ if(!self.ROOT.contains(ev.target)){ self.close(); document.removeEventListener('mousedown',onDoc); } });
|
||||
},
|
||||
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; if(window.E)window.E.dirty=true; this._stage=''; },
|
||||
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; this._md=''; if(window.E)window.E.dirty=true; this._stage=''; },
|
||||
_anchorRect(idx){ const E=window.E; if(!E||!E.blocks)return{left:Math.max(24,window.innerWidth/2-210),top:window.innerHeight/2,bottom:window.innerHeight/2,width:420};
|
||||
const b=E.blocks[idx]; const el=E.getEl(b?b.id:null); if(el){const r=el.getBoundingClientRect();return r;} return {left:24,top:120,bottom:180,width:420}; },
|
||||
_focusIngest(){ const i=this.ROOT.querySelector('.aici-input'); if(i){i.focus();} },
|
||||
@@ -478,12 +478,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
const E=window.E; const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
|
||||
if(!(window.fdAgent&&window.fdAgent.generate)){ this._err('Agent IA non configuré'); return; }
|
||||
window.fdAgent.generate(prompt, ctx).then(function(text){
|
||||
self._html=self._mdToHtml(text||''); self._stage='result'; self.paint();
|
||||
// On conserve le MARKDOWN source : c'est lui qui est inséré (md2b),
|
||||
// pas le texte extrait du HTML rendu qui perd titres/listes/gras.
|
||||
self._md=String(text||'');
|
||||
self._html=self._mdToHtml(self._md); self._stage='result'; self.paint();
|
||||
}).catch(function(err){ self._err((err&&err.message)?err.message:'Erreur'); });
|
||||
},
|
||||
_err(msg){ this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
|
||||
_err(msg){ this._md=''; this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
|
||||
insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync();
|
||||
const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
|
||||
// Markdown source d'abord ; repli sur le texte visible (erreurs).
|
||||
const result=String(this._md||'').trim()||this._resultText(); if(!result)return;
|
||||
E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
|
||||
_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); },
|
||||
toast(m){ if(window.showToast)window.showToast(m,'info'); }
|
||||
};
|
||||
@@ -595,11 +600,19 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
|
||||
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
|
||||
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
|
||||
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
|
||||
// Numéro d'ordre : rang des blocs `numbered_list` contigus (1,2,3…) ;
|
||||
// passé en attribut data-num → le CSS le rend hors du contenu éditable.
|
||||
let numAttr='';
|
||||
if(b.type==='numbered_list'&&idx>=0){
|
||||
const bs=(window.E&&window.E.blocks)||[];
|
||||
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
|
||||
numAttr=` data-num="${idx-s+1}"`;
|
||||
}
|
||||
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
|
||||
return `<div class="block-wrapper" data-id="${b.id}">
|
||||
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
|
||||
${actions}
|
||||
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
|
||||
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -1970,6 +1983,12 @@ applyAIBlocks(text){
|
||||
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
|
||||
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
|
||||
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
||||
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
|
||||
// focus n'a pas rejoint l'entrée du menu → on la répercute au filtre.
|
||||
if(SM._in&&(e.key.length===1||e.key==='Backspace')){
|
||||
const q=(el.textContent||'').replace(/^\s*\//,'');
|
||||
if(SM._in.value!==q){SM._in.value=q;SM.flt();}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(window.AIAC&&window.AIAC.active){
|
||||
@@ -2486,10 +2505,12 @@ applyAIBlocks(text){
|
||||
else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));
|
||||
else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));
|
||||
else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));
|
||||
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
|
||||
else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));
|
||||
// AVANT les puces : « - [ ] … » commence aussi par « - » →
|
||||
// sinon la case à cocher de l'agent devenait une puce.
|
||||
else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));
|
||||
else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));
|
||||
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
|
||||
else if(/^\d+[.)] /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+[.)] /,'')));
|
||||
else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));
|
||||
else bl.push(this.mkB('paragraph',t));
|
||||
}return bl;},
|
||||
|
||||
@@ -18,6 +18,9 @@ function settingsInit() {
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
// Mobile side-navigation drawer for the settings panel (hamburger in topbar)
|
||||
navOpen: false,
|
||||
closeNavOnMobile() { this.navOpen = false; },
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
newTagColor: '#787774',
|
||||
|
||||
@@ -89,6 +89,21 @@ def test_llm_client_providers_listed(client):
|
||||
assert name in PROVIDERS
|
||||
|
||||
|
||||
def test_mistral_defaults_are_tier_safe(client):
|
||||
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
|
||||
Le modèle par défaut et le premier de la liste du test de connexion doivent
|
||||
être des modèles servis par tous les plans."""
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
|
||||
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
|
||||
assert PROVIDERS["mistral"][1] not in tier_blocked
|
||||
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
|
||||
# le fallback du modèle retiré (PROVIDERS default) est présent dans la liste
|
||||
assert PROVIDERS["mistral"][1] in PROVIDER_MODELS["mistral"]
|
||||
# et la liste des modèles Mistral est validée chat à chaque fetch (403 exclus)
|
||||
from app.services.llm_config import _CHAT_VALIDATED_PROVIDERS
|
||||
assert "mistral" in _CHAT_VALIDATED_PROVIDERS
|
||||
|
||||
|
||||
def test_llm_client_openai_compatible_bases(client):
|
||||
"""Providers that need an OpenAI-compatible surface must point at it.
|
||||
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_plus_menu_sections_and_disabled_states():
|
||||
for key in ("'files'", "'skills'", "'connectors'", "'canvases'", "'plugins'", "'memory'"):
|
||||
assert f"key:{key}" in root, key
|
||||
assert "action:'files'" in root and "action:'skills'" in root
|
||||
# phases 1-5 livrées → il reste 1 section « bientôt » (plugins)
|
||||
assert root.count("disabled:true") == 1
|
||||
# v7.58.0 : menu complet — plus aucune section « bientôt » (plugins vivant)
|
||||
assert root.count("disabled:true") == 0
|
||||
|
||||
files = src.split("var FD_PLUS_FILES = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'search'" in files and "action:'browse'" in files
|
||||
|
||||
@@ -64,8 +64,7 @@ def test_canvases_menu_wired(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'canvases'" in root
|
||||
# phases 1-5 livrées → 1 section « bientôt » restante (plugins)
|
||||
assert root.count("disabled:true") == 1
|
||||
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
|
||||
for action in ("'canvas'", "'canvas-create'", "'canvas-insert'", "'canvas-save'"):
|
||||
assert action in src, action
|
||||
# piège /use : la clé builtin part DANS LE BODY, pas en query string
|
||||
|
||||
@@ -129,8 +129,7 @@ def test_memory_menu_toggle_wired():
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'memory'" in root
|
||||
assert "bientôt (phase 4)" not in root
|
||||
# phases 1-5 livrées → seul Plugins reste désactivé
|
||||
assert root.count("disabled:true") == 1
|
||||
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
|
||||
assert "toggleMemory()" in src
|
||||
assert "memory_enabled: next" in src
|
||||
engine = ENGINE.read_text(encoding="utf-8")
|
||||
|
||||
@@ -158,8 +158,7 @@ def test_connectors_menu_wired(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "action:'connectors'" in root
|
||||
# phases 1-5 livrées → seule la section Plugins reste « bientôt »
|
||||
assert root.count("disabled:true") == 1
|
||||
assert root.count("disabled:true") == 0 # v7.58.0 : menu complet
|
||||
for action in ("'connector'", "'connector-new'", "'connector-probe'",
|
||||
"'connector-toggle'", "'connector-delete'"):
|
||||
assert action in src, action
|
||||
|
||||
@@ -194,9 +194,9 @@ def test_connectors_menu_wired_for_presets(client):
|
||||
for token in ('value="discord"', 'value="telegram"', 'value="mcp"',
|
||||
'connectorForm.kind'):
|
||||
assert token in html, token
|
||||
# 1 seule section « bientôt » restante (plugins)
|
||||
# v7.58.0 : plus aucune section « bientôt » (plugins rendu vivant)
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert root.count("disabled:true") == 1
|
||||
assert root.count("disabled:true") == 0
|
||||
resp = client.get("/accounts")
|
||||
assert resp.status_code == 200 and "connectorPreset" in resp.text
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""v7.58.0 — Add plugins : registre persistant + on/off à effet réel."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from conftest import anon_csrf
|
||||
|
||||
from app.services import plugins
|
||||
from app.services.tool_registry import ToolRegistry
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
JS_PATH = ROOT / "static" / "js" / "agent_panel_2.js"
|
||||
PANEL_HTML = ROOT / "app" / "templates" / "agent_panel.html"
|
||||
SETTINGS_HTML = ROOT / "app" / "templates" / "settings.html"
|
||||
AUTOMATIONS_PY = ROOT / "app" / "services" / "automations.py"
|
||||
|
||||
|
||||
def _set(client, slug: str, enabled: bool) -> dict:
|
||||
r = client.patch(f"/api/agent/plugins/{slug}", json={"enabled": enabled})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# ── Catalogue ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_catalog_lists_the_three_wired_plugins(client):
|
||||
data = client.get("/api/agent/plugins").json()["plugins"]
|
||||
assert [p["slug"] for p in data] == ["web-tools", "web-clipper", "automations"]
|
||||
assert all(p["enabled"] for p in data)
|
||||
assert all(p["name"] and p["description"] for p in data)
|
||||
|
||||
|
||||
def test_unknown_slug_is_404(client):
|
||||
assert client.patch("/api/agent/plugins/nope",
|
||||
json={"enabled": False}).status_code == 404
|
||||
|
||||
|
||||
def test_toggle_persists_in_db(client):
|
||||
_set(client, "web-clipper", False)
|
||||
assert plugins.is_enabled("web-clipper") is False # relu depuis la base
|
||||
_set(client, "web-clipper", True)
|
||||
assert plugins.is_enabled("web-clipper") is True
|
||||
|
||||
|
||||
# ── Effet réel : routes refusées ────────────────────────────────────────────
|
||||
|
||||
def test_automations_routes_refused_when_plugin_off(client):
|
||||
# 404 hors /api = redirection vers /workspaces (handler unifié de main.py)
|
||||
def get():
|
||||
return client.get("/workspace/automations", follow_redirects=False)
|
||||
|
||||
assert get().status_code == 200
|
||||
_set(client, "automations", False)
|
||||
assert get().status_code == 302
|
||||
assert get().headers["location"] == "/workspaces"
|
||||
assert client.post("/workspace/automations", json={},
|
||||
follow_redirects=False).status_code == 302
|
||||
assert plugins.is_enabled("automations") is False
|
||||
_set(client, "automations", True)
|
||||
assert get().status_code == 200
|
||||
|
||||
|
||||
def test_scheduler_tick_guarded_when_automations_off(client):
|
||||
# le scheduler doit vérifier le plugin avant chaque tick ( effet réel, pas un
|
||||
# drapeau lu nulle part )
|
||||
src = AUTOMATIONS_PY.read_text(encoding="utf-8")
|
||||
assert 'plugins.is_enabled("automations")' in src
|
||||
_set(client, "automations", False)
|
||||
assert plugins.is_enabled("automations") is False
|
||||
|
||||
|
||||
def test_web_clipper_api_and_page_refused_when_plugin_off(client):
|
||||
def page():
|
||||
return client.get("/extensions", follow_redirects=False)
|
||||
|
||||
assert client.get("/api/v2/web-clipper/status").status_code == 200
|
||||
assert page().status_code == 200
|
||||
_set(client, "web-clipper", False)
|
||||
assert client.get("/api/v2/web-clipper/status").status_code == 404
|
||||
assert client.post("/api/v2/web-clipper/clip", json={}).status_code == 404
|
||||
assert page().status_code == 302 and page().headers["location"] == "/workspaces"
|
||||
_set(client, "web-clipper", True)
|
||||
assert client.get("/api/v2/web-clipper/status").status_code == 200
|
||||
assert page().status_code == 200
|
||||
|
||||
|
||||
# ── Effet réel : outils de l'agent ──────────────────────────────────────────
|
||||
|
||||
def test_web_tools_leaves_the_llm_registry_when_plugin_off(client):
|
||||
names = {t["name"] for t in ToolRegistry().schema()}
|
||||
assert {"web_search", "fetch_url"} <= names
|
||||
|
||||
_set(client, "web-tools", False)
|
||||
names = {t["name"] for t in ToolRegistry().schema()}
|
||||
assert not ({"web_search", "fetch_url"} & names)
|
||||
res = asyncio.run(ToolRegistry().execute("fetch_url",
|
||||
{"url": "https://example.com"}))
|
||||
assert res.status == "error" and "inconnu" in res.message.lower()
|
||||
|
||||
_set(client, "web-tools", True)
|
||||
names = {t["name"] for t in ToolRegistry().schema()}
|
||||
assert {"web_search", "fetch_url"} <= names
|
||||
|
||||
|
||||
# ── UI : settings rendu côté serveur + menu + ──────────────────────────────
|
||||
|
||||
def test_settings_hides_disabled_plugins(client):
|
||||
html = client.get("/settings").text
|
||||
assert "navTo('extensions')" in html and "navTo('automations')" in html # ON
|
||||
|
||||
_set(client, "automations", False)
|
||||
_set(client, "web-clipper", False)
|
||||
html = client.get("/settings").text
|
||||
assert "navTo('automations')" not in html # nav retirée
|
||||
assert "navTo('extensions')" not in html
|
||||
assert "x-show=\"false && activeSection==='automations'\"" in html
|
||||
|
||||
_set(client, "automations", True)
|
||||
_set(client, "web-clipper", True)
|
||||
html = client.get("/settings").text
|
||||
assert "navTo('automations')" in html and "navTo('extensions')" in html
|
||||
|
||||
|
||||
def test_menu_catalogue_is_live(client):
|
||||
src = JS_PATH.read_text(encoding="utf-8")
|
||||
for token in ("action:'plugins'", "_pluginItems()", "pluginToggle(slug)",
|
||||
"/api/agent/plugins", "Add plugins"):
|
||||
assert token in src, token
|
||||
# la section n'est plus une coquille « bientôt »
|
||||
root = src.split("var FD_PLUS_MENU = [", 1)[1].split("];", 1)[0]
|
||||
assert "disabled:true" not in root
|
||||
panel = PANEL_HTML.read_text(encoding="utf-8")
|
||||
assert "plusSection!=='root'" in panel # en-tête/rétour génériques
|
||||
settings = SETTINGS_HTML.read_text(encoding="utf-8")
|
||||
assert settings.count("plugin_enabled(") >= 4
|
||||
|
||||
|
||||
def test_plugins_routes_require_session(client):
|
||||
anon_csrf(client)
|
||||
assert client.get("/api/agent/plugins").status_code == 401
|
||||
assert client.patch("/api/agent/plugins/automations",
|
||||
json={"enabled": False}).status_code == 401
|
||||
@@ -0,0 +1,100 @@
|
||||
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
|
||||
format, listes numérotées invisibles (agent + /numbered list)."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
HELP_HTML = ROOT / "app" / "templates" / "help.html"
|
||||
HELP_JS = ROOT / "static" / "js" / "help.js"
|
||||
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
|
||||
APP_CSS = ROOT / "static" / "css" / "app.css"
|
||||
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
|
||||
|
||||
|
||||
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
|
||||
|
||||
def test_help_page_has_close_button():
|
||||
html = HELP_HTML.read_text(encoding="utf-8")
|
||||
js = HELP_JS.read_text(encoding="utf-8")
|
||||
css = SETTINGS_CSS.read_text(encoding="utf-8")
|
||||
assert 'class="settings-close"' in html # bouton ✕ présent
|
||||
assert '@click="close()"' in html
|
||||
assert "close()" in js and "history.back()" in js # méthode réelle
|
||||
assert ".settings-close{" in css # style partagé (32/44px)
|
||||
assert "position:relative" in css.split(".settings-panel{")[1][:40]
|
||||
|
||||
|
||||
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
|
||||
|
||||
def test_ai_insert_below_keeps_source_markdown():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
assert "self._md=String(text||'')" in src # source conservée
|
||||
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
|
||||
assert src.count("this._md='';") >= 2 # close() + _err()
|
||||
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
|
||||
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
|
||||
|
||||
|
||||
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
|
||||
|
||||
def test_numbered_list_renders_its_number():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
css = APP_CSS.read_text(encoding="utf-8")
|
||||
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
|
||||
assert ".block-numbered[data-num]::before" in css
|
||||
assert 'content: attr(data-num) ". "' in css
|
||||
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
|
||||
assert r"/^\d+[.)] /" in src
|
||||
|
||||
|
||||
def test_slash_query_typed_in_block_is_synced_to_filter():
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
|
||||
assert "SM._in.value=q" in src
|
||||
assert "SM.flt();" in src
|
||||
|
||||
|
||||
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
|
||||
|
||||
def _md2b_source() -> str:
|
||||
src = EDITOR_JS.read_text(encoding="utf-8")
|
||||
i = src.index("md2b(md){")
|
||||
j = src.index("return bl;},", i) + len("return bl;},")
|
||||
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
|
||||
return snippet
|
||||
|
||||
|
||||
def test_md2b_converts_numbered_lists_for_real():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node absent : test comportemental md2b ignoré")
|
||||
md = ("# Titre\n"
|
||||
"1. premier\n"
|
||||
"2. second\n"
|
||||
"3) troisième\n"
|
||||
"- puce\n"
|
||||
"- [ ] à faire\n"
|
||||
"> citation\n")
|
||||
script = (
|
||||
"function _tblIsData(){return false;}\n"
|
||||
"function _tblSplitLine(){return [];}\n"
|
||||
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
|
||||
"if(e)Object.assign(o,e);return o;},\n"
|
||||
f"{_md2b_source()}}};\n"
|
||||
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
|
||||
)
|
||||
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
|
||||
timeout=30, encoding="utf-8")
|
||||
assert out.returncode == 0, out.stderr
|
||||
blocks = json.loads(out.stdout)
|
||||
assert [b["type"] for b in blocks] == [
|
||||
"heading_1",
|
||||
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
|
||||
"bulleted_list", "to_do", "quote",
|
||||
]
|
||||
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]
|
||||
Reference in New Issue
Block a user