diff --git a/CHANGELOG.md b/CHANGELOG.md index 286dbe3..6c2751a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog — FlowDeck +## v4.12.0 (2026-09-05) — Agent & IA : refonte des réglages, fournisseurs activés/fonctionnels dans le panneau, contexte document & guide + +> Refonte de la section **Settings → Agent & IA** pour la rendre plus claire, et aligne le panneau +> Agent sur la réalité de la configuration : **seuls les fournisseurs configurés ET dont le test de +> connexion a réussi** sont proposés (avant, tous les fournisseurs connus apparaissaient, même non +> configurés). Le panneau sait aussi sur quel **document** l'utilisateur travaille et peut guider +> sur l'utilisation de l'application quand aucun document n'est ouvert. + +### Added +- **Vérification des fournisseurs** : colonnes `verified` / `verified_at` / `last_error` sur + `user_llm_keys` et `llm_config` (migration automatique au démarrage). Un fournisseur est + « fonctionnel » après un test de connexion réussi ou un chargement de modèles réussi. +- **Endpoint non-admin** `POST /api/agent/keys/{provider}/test` : teste la clé enregistrée de + l'utilisateur (sans mock) et met à jour l'état `verified` ; l'endpoint admin `/providers/test` + marque le fournisseur par défaut de l'espace. +- **`/api/agent/providers` enrichi** : chaque provider expose `configured`, `verified`, + `functional`, `last_error` et `source` (user/global/open) ; `default_provider` + `default_verified` + en tête de réponse. +- **Panneau Agent** : les listes fournisseur/modèle ne contiennent plus que les providers + **activés et fonctionnels** (+ mode hors-ligne). Bandeau « Aucun fournisseur activé » avec lien + vers les réglages et rappel des clés « à tester » le cas échéant. +- **Contexte document** : le panneau joint automatiquement la page ouverte (titre + contenu des + blocs) à chaque requête — l'agent peut interagir avec le document courant. +- **Guide d'utilisation** : le prompt système intègre un guide FlowDeck concis pour répondre aux + questions « comment faire… ? » (pages, collections/vues, Gitea, corbeille, réglages…) lorsqu'aucun + document n'est fourni. + +### Changed +- **Settings → Agent & IA refondu** : carte de statut (prêt / N fournisseurs actifs / mode + hors-ligne), cartes de fournisseurs avec badges d'état, erreur du dernier test, boutons + « Enregistrer & tester », « Tester », « Modèles », « Supprimer » et encart d'aide. +- Le bouton **Save configuration** admin gagne un rappel « Tester la connexion » pour activer le + fournisseur pour tous ; changer de clé réinitialise la vérification. + +### Fix +- Saisie clé : une clé vide conserve désormais l'existante aussi pour la config globale (au lieu de + l'effacer), et un changement de clé invalide l'ancien statut `verified`. + +### Infra +- `VERSION` → 4.12.0 ; `app/main.py` version → 4.12.0. + ## v4.11.1 (2026-09-05) — Correction & intégration des points d'entrée IA > Aligne les entrées AI de l'interface sur le FlowDeck Agent (alignement Guide Notion AI) et corrige diff --git a/VERSION b/VERSION index d782fca..815588e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.1 +4.12.0 diff --git a/app/db.py b/app/db.py index 424ed05..aa74549 100644 --- a/app/db.py +++ b/app/db.py @@ -737,6 +737,10 @@ def init_db(): model TEXT DEFAULT '', api_key TEXT DEFAULT '', api_base TEXT DEFAULT '', + verified INTEGER NOT NULL DEFAULT 0, + verified_model TEXT DEFAULT '', + verified_at TIMESTAMP, + last_error TEXT DEFAULT '', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) @@ -752,12 +756,38 @@ def init_db(): api_base TEXT NOT NULL DEFAULT '', default_model TEXT DEFAULT '', models_json TEXT NOT NULL DEFAULT '[]', + verified INTEGER NOT NULL DEFAULT 0, + verified_model TEXT DEFAULT '', + verified_at TIMESTAMP, + last_error TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, provider) ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)") + # v4.12: provider activation/verification — a provider is only offered in + # the Agent UI once it is configured AND its connection test succeeded. + for col, ddl in ( + ("verified", "INTEGER NOT NULL DEFAULT 0"), + ("verified_model", "TEXT DEFAULT ''"), + ("verified_at", "TIMESTAMP"), + ("last_error", "TEXT DEFAULT ''"), + ): + try: + conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}") + except sqlite3.OperationalError: + pass # column already exists + for col, ddl in ( + ("verified", "INTEGER NOT NULL DEFAULT 0"), + ("verified_model", "TEXT DEFAULT ''"), + ("verified_at", "TIMESTAMP"), + ("last_error", "TEXT DEFAULT ''"), + ): + try: + conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}") + except sqlite3.OperationalError: + pass # column already exists # Migration: per-conversation provider/model override columns for col in ("provider", "model"): try: diff --git a/app/main.py b/app/main.py index bf9e5f4..a0991c9 100644 --- a/app/main.py +++ b/app/main.py @@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="4.11.1", + version="4.12.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/agent.py b/app/routers/agent.py index 7197104..29e693f 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -15,11 +15,12 @@ from app.db import get_conn from app.config import settings from app.auth.session import get_current_user from app.services.agent_engine import AgentEngine, undo_action -from app.services.llm_client import LLMClient, PROVIDERS +from app.services.llm_client import LLMClient, PROVIDERS, PROVIDER_MODELS from app.services.llm_config import ( get_llm_config, set_llm_config, provider_info, get_user_llm_key, list_user_llm_keys, upsert_user_llm_key, delete_user_llm_key, fetch_provider_models, + mark_llm_config_verified, mark_user_llm_key_verified, ) from app.services.tool_registry import ToolRegistry from app.services.permission_manager import PermissionManager @@ -298,6 +299,7 @@ async def run_conversation(request: Request, conversation_id: int): mentions=body.get("mentions"), files=body.get("files"), skill_id=body.get("skill_id"), + extra_context=body.get("context"), ): yield f"data: {json.dumps(ev, ensure_ascii=False)}\n\n" @@ -427,12 +429,26 @@ async def list_tools(request: Request): @router.get("/providers") async def list_providers(request: Request): - """Expose the LLM providers list + current config to the UI.""" + """Expose the LLM providers list + current config to the UI. + + Every provider known by FlowDeck is returned (the Settings UI lets the user + configure any of them). Each provider also carries activation flags used by + the Agent panel to offer only the providers that are *configured and + functional* for the current user: + + - ``configured`` : a usable credential exists (own key OR the workspace + default provider carries a key, OR no key is required). + - ``verified`` : the last connection test / model fetch succeeded. + - ``functional`` : the provider is ready to chat (verified, or `offline`). + """ user_id = await _current_user_id(request) llm = LLMClient() cfg = get_llm_config() keys = list_user_llm_keys(user_id) keys_map = {k["provider"]: k for k in keys} + default_provider = (cfg.get("provider") or "offline").lower() + global_has_key = bool(cfg.get("api_key")) and default_provider != "offline" + global_verified = bool(cfg.get("verified")) and global_has_key provs = provider_info() for p in provs: k = keys_map.get(p["id"]) @@ -440,7 +456,28 @@ async def list_providers(request: Request): for m in k["models"]: if m not in p["models"]: p["models"].append(m) - p["has_key"] = bool(k and k["has_key"]) + user_has_key = bool(k and k["has_key"]) + is_default = p["id"] == default_provider + configured = bool(k and k["has_key"]) or (is_default and global_has_key) \ + or not p["requires_key"] + user_verified = bool(k and k.get("verified")) + verified = (user_verified and bool(k and k["has_key"])) or \ + (is_default and global_has_key and global_verified) + if not p["requires_key"] and not (k and k["has_key"]) and not (is_default and global_has_key): + # ollama / offline: no key needed, but only offline is usable as-is; + # local providers must still pass a connection test. + verified = bool(user_verified or (is_default and global_verified)) \ + if p["id"] != "offline" else True + p["has_key"] = user_has_key + p["configured"] = bool(configured) + p["source"] = ("user" if user_has_key else + "global" if is_default and global_has_key else "open") + p["verified"] = bool(verified) + p["verified_model"] = (k.get("verified_model") if user_has_key and k else + cfg.get("verified_model") if is_default and global_has_key else "") or "" + p["last_error"] = (k.get("last_error") if user_has_key and k else + cfg.get("last_error") if is_default and global_has_key else "") or "" + p["functional"] = p["id"] == "offline" or bool(verified) return { "provider": llm.provider, "model": llm.default_model, @@ -448,6 +485,8 @@ async def list_providers(request: Request): "max_iterations": settings.agent_max_iterations, "api_base": cfg["api_base"], "has_api_key": bool(cfg["api_key"]), + "default_provider": default_provider, + "default_verified": bool(cfg.get("verified")), "providers": provs, "keys": keys, } @@ -494,10 +533,68 @@ async def delete_llm_key(request: Request, llm_provider: str): return {"status": "deleted", "provider": provider} +@router.post("/keys/{llm_provider}/test") +async def test_user_llm_key(request: Request, llm_provider: str): + """Non-admin: verify one of the user's own provider keys (no mock fallback). + + On success the provider is flagged ``verified`` so it can be offered in the + Agent panel; on failure the stored error is kept for display in Settings. + """ + user_id = await _current_user_id(request) + provider = llm_provider.lower() + if provider not in PROVIDERS: + raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") + body = await request.json() if request.headers.get("content-type") else {} + stored = get_user_llm_key(user_id, provider) + api_key = (body.get("api_key") or "").strip() + api_base = (body.get("api_base") or "").strip() + if not api_key and stored and stored.get("api_key"): + api_key = stored["api_key"] + if not api_base: + api_base = (stored.get("api_base") or "").strip() if stored else "" + _, provider_default = PROVIDERS.get(provider, (None, "gpt-4o")) + candidates = PROVIDER_MODELS.get(provider) or [provider_default] + model = (body.get("model") or "").strip() or \ + ((stored or {}).get("default_model") or "") or \ + next((m for m in candidates if m), provider_default) + # Only record the result against the *stored* credential when it is the one + # being tested (avoids flagging a stored key from a test run on a typed key). + can_record = bool(stored) and ( + not (stored or {}).get("api_key") or + api_key == (stored or {}).get("api_key") + ) + llm = LLMClient(provider=provider, api_key=api_key, api_base=api_base or None) + try: + resp = await llm.ping(model=model or None) + except Exception as exc: # noqa: BLE001 — surface connectivity errors + if can_record: + mark_user_llm_key_verified(user_id, provider, False, error=str(exc)) + return {"ok": False, "provider": provider, "error": str(exc), "verified": False} + if not stored and provider in ("ollama", "offline"): + stored = upsert_user_llm_key(user_id, provider, api_key="", api_base=api_base) + can_record = True + if can_record: + mark_user_llm_key_verified( + user_id, provider, True, + model=resp.model or model or "", + ) + return { + "ok": True, + "provider": provider, + "model": resp.model or model or "", + "reply": (resp.text or "").strip()[:200], + "verified": bool(can_record), + } + + @router.post("/keys/{llm_provider}/models") async def fetch_llm_models(request: Request, llm_provider: str): """Fetch the live model list from a provider. Falls back to the user's - stored key when no key is supplied in the body.""" + stored key when no key is supplied in the body. + + A successful fetch proves connectivity, so when it used the *stored* key the + provider is flagged ``verified`` (functional) for the Agent panel. + """ user_id = await _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: @@ -505,13 +602,18 @@ async def fetch_llm_models(request: Request, llm_provider: str): body = await request.json() if request.headers.get("content-type") else {} api_key = (body.get("api_key") or "").strip() api_base = (body.get("api_base") or "").strip() + used_stored = False if not api_key: stored = get_user_llm_key(user_id, provider) if stored and stored.get("api_key"): api_key = stored["api_key"] api_base = api_base or (stored.get("api_base") or "").strip() + used_stored = True try: models = await fetch_provider_models(provider, api_key=api_key, api_base=api_base) + if used_stored and models: + mark_user_llm_key_verified(user_id, provider, True, + model=(body.get("default_model") or "").strip()) return {"ok": True, "provider": provider, "models": models} except Exception as exc: # noqa: BLE001 — surface connectivity errors return {"ok": False, "provider": provider, "error": str(exc)} @@ -543,26 +645,37 @@ async def update_provider_config(request: Request): @router.post("/providers/test") async def test_provider_config(request: Request): - """Admin: verify a provider is reachable (no mock fallback).""" + """Admin: verify a provider is reachable (no mock fallback). + + A successful test flags the workspace default provider as ``verified`` so it + becomes available (functional) for every user in the Agent panel. + """ await _current_admin(request) body = await request.json() if request.headers.get("content-type") else {} - provider = (body.get("provider") or "").strip().lower() + provider = (body.get("provider") or "").strip().lower() or None if provider and provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") llm = LLMClient( - provider=provider or None, + provider=provider, api_key=body.get("api_key"), api_base=(body.get("api_base") or "").strip() or None, ) try: resp = await llm.ping(model=(body.get("model") or "").strip() or None) - return { - "ok": True, - "model": resp.model or (body.get("model") or "").strip() or llm.default_model, - "reply": (resp.text or "").strip()[:200], - } except Exception as exc: # noqa: BLE001 — surface real connectivity errors - return {"ok": False, "error": str(exc)} + cfg = get_llm_config() + if (provider or cfg.get("provider")) == cfg.get("provider"): + mark_llm_config_verified(False, error=str(exc)) + return {"ok": False, "error": str(exc), "verified": False} + cfg = get_llm_config() + if (provider or cfg.get("provider")) == cfg.get("provider"): + mark_llm_config_verified(True, model=resp.model or llm.default_model or "") + return { + "ok": True, + "model": resp.model or (body.get("model") or "").strip() or llm.default_model, + "reply": (resp.text or "").strip()[:200], + "verified": True, + } # ── Agents by id (registered LAST so static routes /tools, /skills, … win) ── diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index c1d03b0..6bcf44c 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -25,6 +25,19 @@ logger = logging.getLogger(__name__) MAX_ITERATIONS = 12 +# Compact in-app guide so the LLM can answer « comment faire… ? » questions even +# when no document is attached to the conversation (generic help / onboarding). +APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? ») +- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées. +- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view. +- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils. +- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »). +- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections. +- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque. +- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global). +- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application. +Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes.""" + class AgentEngine: def __init__(self, user_id: int, workspace_id: int | None = None, @@ -63,13 +76,16 @@ class AgentEngine: lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}") if skill: lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}") + # L'utilisateur peut poser des questions d'aide sans contexte de document ; + # le guide intégré permet d'y répondre (aucun outil requis). + lines.append("\n" + APP_GUIDE) return "\n".join(lines) # ── Main run (async generator of SSE events) ── async def run(self, conversation_id: int, objective: str, *, model: str | None = None, mentions: list[str] | None = None, files: list[dict] | None = None, - skill_id: int | None = None): + skill_id: int | None = None, extra_context: str | None = None): agent = self._load_agent(conversation_id) scope = json.loads(agent.get("scope_json") or "{}") approval_mode = agent.get("approval_mode") or "auto" @@ -78,6 +94,8 @@ class AgentEngine: skill = self._load_skill(skill_id, scope) if skill_id else None system = self._build_system_prompt(agent, skill) context = self.ctx.build(mentions=mentions, files=files) + if extra_context and extra_context.strip(): + context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip() messages = [ {"role": "system", "content": system}, diff --git a/app/services/llm_config.py b/app/services/llm_config.py index 5e6a893..8c3c0f1 100644 --- a/app/services/llm_config.py +++ b/app/services/llm_config.py @@ -20,6 +20,7 @@ __all__ = [ "get_llm_config", "set_llm_config", "provider_info", "get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key", "delete_user_llm_key", "fetch_provider_models", + "mark_llm_config_verified", "mark_user_llm_key_verified", ] @@ -30,13 +31,18 @@ def get_llm_config() -> dict: "model": settings.llm_model or "gpt-4o", "api_key": settings.llm_api_key or "", "api_base": settings.llm_api_base or "", + "verified": 0, + "verified_model": "", + "verified_at": "", + "last_error": "", } try: from app.db import get_conn with get_conn() as conn: row = conn.execute( - "SELECT provider, model, api_key, api_base FROM llm_config WHERE id=1" + "SELECT provider, model, api_key, api_base, verified, verified_model, " + "verified_at, last_error FROM llm_config WHERE id=1" ).fetchone() except Exception: # noqa: BLE001 — DB not ready yet → env defaults return cfg @@ -44,12 +50,22 @@ def get_llm_config() -> dict: for key in ("provider", "model", "api_key", "api_base"): if row[key]: cfg[key] = row[key] + if row["provider"]: + cfg["verified"] = row["verified"] or 0 + cfg["verified_model"] = row["verified_model"] or "" + cfg["verified_at"] = row["verified_at"] or "" + cfg["last_error"] = row["last_error"] or "" return cfg def set_llm_config(*, provider: str | None = None, model: str | None = None, api_key: str | None = None, api_base: str | None = None) -> dict: - """Upsert the runtime LLM config row (id=1) and return the new effective config.""" + """Upsert the runtime LLM config row (id=1) and return the new effective config. + + An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak + the model/base without re-typing the key). Changing the API key resets the + `verified` flag — the provider has to pass a connection test again. + """ from app.db import get_conn cfg = get_llm_config() @@ -57,25 +73,58 @@ def set_llm_config(*, provider: str | None = None, model: str | None = None, cfg["provider"] = provider if model is not None: cfg["model"] = model - if api_key is not None: - cfg["api_key"] = api_key - if api_base is not None: - cfg["api_base"] = api_base + if api_key is not None and api_key.strip(): + key_changed = cfg.get("api_key") != api_key.strip() + cfg["api_key"] = api_key.strip() + if key_changed: + cfg["verified"] = 0 + cfg["verified_model"] = "" + cfg["last_error"] = "" + if api_base is not None and api_base.strip(): + cfg["api_base"] = api_base.strip() with get_conn() as conn: conn.execute( - """INSERT INTO llm_config (id, provider, model, api_key, api_base, updated_at) - VALUES (1, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """INSERT INTO llm_config (id, provider, model, api_key, api_base, + verified, verified_model, last_error, updated_at) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET provider=excluded.provider, model=excluded.model, api_key=excluded.api_key, api_base=excluded.api_base, + verified=excluded.verified, verified_model=excluded.verified_model, + last_error=excluded.last_error, updated_at=CURRENT_TIMESTAMP""", - (cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"]), + (cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"], + cfg.get("verified", 0), cfg.get("verified_model", ""), + cfg.get("last_error", "")), ) conn.commit() return cfg +def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None: + """Record the outcome of the admin 'Test connection' for the global default.""" + from app.db import get_conn + + with get_conn() as conn: + row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone() + provider = row["provider"] if row else (settings.llm_provider or "offline") + conn.execute( + """INSERT INTO llm_config (id, provider, model, verified, verified_model, + verified_at, last_error, updated_at) + VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP) + ON CONFLICT(id) DO UPDATE SET + verified=excluded.verified, + verified_model=excluded.verified_model, + verified_at=excluded.verified_at, + last_error=excluded.last_error, + updated_at=CURRENT_TIMESTAMP""", + (provider, 1 if ok else 0, model if ok else "", + 1 if ok else 0, "" if ok else error), + ) + conn.commit() + + def provider_info() -> list[dict]: """Providers list for the UI: known models + whether an API key is required.""" out: list[dict] = [] @@ -98,12 +147,21 @@ def provider_info() -> list[dict]: def _mask_key(row) -> dict: """Public view of a stored key row: never exposes the raw api_key.""" + def _get(name, default=""): + try: + v = row[name] + return default if v is None else v + except (KeyError, IndexError): + return default return { "provider": row["provider"], "api_base": row["api_base"] or "", "default_model": row["default_model"] or "", "models": json.loads(row["models_json"] or "[]"), "has_key": bool(row["api_key"]), + "verified": bool(_get("verified", 0)), + "verified_model": _get("verified_model") or "", + "last_error": _get("last_error") or "", } @@ -136,7 +194,8 @@ def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "", api_base: str = "", default_model: str = "", models: Optional[list[str]] = None) -> dict: """Upsert a user's provider key. Empty api_key keeps the existing one - (allows saving model/base without re-typing the key).""" + (allows saving model/base without re-typing the key). Saving a *different* + key resets the `verified` flag so the provider must pass a test again.""" from app.db import get_conn provider = provider.lower() @@ -147,24 +206,48 @@ def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "", new_models = models if models is not None else ( json.loads(existing["models_json"]) if existing and existing.get("models_json") else [] ) + key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key) + # A changed key invalidates the previous verification; keep it otherwise. + verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0 with get_conn() as conn: conn.execute( - """INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id, provider) DO UPDATE SET api_key=excluded.api_key, api_base=excluded.api_base, default_model=excluded.default_model, models_json=excluded.models_json, + verified=excluded.verified, updated_at=CURRENT_TIMESTAMP""", (user_id, provider, new_key, new_base, new_model, - json.dumps(new_models, ensure_ascii=False)), + json.dumps(new_models, ensure_ascii=False), verified), ) conn.commit() return get_user_llm_key(user_id, provider) or { "provider": provider, "api_key": new_key, "api_base": new_base, "default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False), + "verified": verified, } +def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *, + model: str = "", error: str = "") -> None: + """Record the outcome of a connection test on one of the user's providers.""" + from app.db import get_conn + + provider = provider.lower() + with get_conn() as conn: + conn.execute( + """UPDATE user_llm_keys + SET verified=?, verified_model=?, last_error=?, + verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END, + updated_at=CURRENT_TIMESTAMP + WHERE user_id=? AND provider=?""", + (1 if ok else 0, model if ok else "", "" if ok else error, + 1 if ok else 0, user_id, provider), + ) + conn.commit() + + def delete_user_llm_key(user_id: int, provider: str) -> None: from app.db import get_conn diff --git a/app/templates/agent_panel.html b/app/templates/agent_panel.html index 03898a9..b142789 100644 --- a/app/templates/agent_panel.html +++ b/app/templates/agent_panel.html @@ -39,6 +39,10 @@ .fd-agent-cmd-item span{color:var(--text-dim,#999);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fd-agent-gear{background:none;border:none;color:var(--text-dim,#999);font-size:14px;cursor:pointer;padding:2px 4px;border-radius:6px;text-decoration:none;line-height:1} .fd-agent-gear:hover{background:var(--bg-hover,#333);color:var(--text,#fff)} +.fd-agent-noconfig{padding:8px 10px;font-size:12px;color:var(--text-dim,#ccc);background:rgba(227,98,98,.10);border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;flex-wrap:wrap} +.fd-agent-noconfig a{color:var(--accent,#2383E2);text-decoration:none;font-weight:600} +.fd-agent-ctx{font-size:11px;color:var(--text-dim,#999);padding:5px 12px;border-bottom:1px solid var(--border,rgba(255,255,255,.06));display:flex;gap:6px;align-items:center;background:rgba(35,131,226,.08)} + @@ -53,7 +57,7 @@