diff --git a/backend/app/agent.py b/backend/app/agent.py index dad3b8e..3360f98 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -40,9 +40,17 @@ async def run_agent( convo: list[dict], *, options: dict | None = None, - tools_enabled: bool = True, + enabled_tools: list[str] | None = None, ) -> AsyncIterator[dict]: - tools = TOOL_DEFINITIONS if tools_enabled else None + # enabled_tools=None -> tous les outils ; liste vide -> aucun outil. + if enabled_tools is None: + tools = TOOL_DEFINITIONS + elif enabled_tools: + tools = [ + t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools + ] + else: + tools = None collected: list[dict] = [] text_parts: list[str] = [] diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py new file mode 100644 index 0000000..286f653 --- /dev/null +++ b/backend/app/agent_config.py @@ -0,0 +1,66 @@ +"""Profil de configuration de l'agent : invite système, génération, outils. + +Persisté en base sous la clé `agent`. Fournit les valeurs par défaut et la +fusion avec ce qui est stocké, pour rester robuste aux montées de version. +""" +from __future__ import annotations + +from . import db + +CONFIG_KEY = "agent" + +DEFAULT_SYSTEM_PROMPT = ( + "Tu es Loki, un assistant de développement local agentique. Tu disposes " + "d'outils pour lire, écrire et lister des fichiers dans le workspace. " + "Utilise-les pour accomplir les tâches concrètement, puis réponds de façon " + "concise en français. Après avoir écrit un fichier, propose un aperçu." +) + +# Outils réellement disponibles (les sensibles arrivent en phase 7). +AVAILABLE_TOOLS = ["read_file", "write_file", "list_dir"] + +DEFAULT_CONFIG: dict = { + "system_prompt": DEFAULT_SYSTEM_PROMPT, + "temperature": 0.7, + "top_p": 0.9, + "top_k": 40, + "max_tokens": 2048, + "tools": {name: True for name in AVAILABLE_TOOLS}, +} + + +def get_config() -> dict: + """Config courante = défauts fusionnés avec le stockage.""" + stored = db.get_config_value(CONFIG_KEY) or {} + cfg = {**DEFAULT_CONFIG, **stored} + cfg["tools"] = { + name: bool(stored.get("tools", {}).get(name, True)) + for name in AVAILABLE_TOOLS + } + return cfg + + +def save_config(patch: dict) -> dict: + """Applique une mise à jour partielle et renvoie la config complète.""" + cfg = {**get_config(), **{k: v for k, v in patch.items() if v is not None}} + if "tools" in patch and patch["tools"]: + cfg["tools"] = { + name: bool(patch["tools"].get(name, cfg["tools"].get(name, True))) + for name in AVAILABLE_TOOLS + } + db.set_config_value(CONFIG_KEY, cfg) + return get_config() + + +def ollama_options(cfg: dict) -> dict: + """Traduit la config en options de génération Ollama.""" + return { + "temperature": cfg["temperature"], + "top_p": cfg["top_p"], + "top_k": cfg["top_k"], + "num_predict": cfg["max_tokens"], + } + + +def enabled_tool_names(cfg: dict) -> list[str]: + return [name for name, on in cfg["tools"].items() if on] diff --git a/backend/app/db.py b/backend/app/db.py index 17641d0..832c430 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -54,6 +54,10 @@ def init_db() -> None: cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")} if "meta" not in cols: conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT") + # Table clé/valeur pour la configuration de l'agent. + conn.execute( + "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)" + ) def _now() -> float: @@ -160,3 +164,22 @@ def list_messages_for_model(sid: str) -> list[dict]: {"role": m["role"], "content": m["content"]} for m in list_messages(sid) ] + + +# ── Configuration (clé/valeur JSON) ────────────────────────────────────── +def get_config_value(key: str) -> dict | None: + with _LOCK, _connect() as conn: + row = conn.execute( + "SELECT value FROM config WHERE key = ?", (key,) + ).fetchone() + return json.loads(row["value"]) if row else None + + +def set_config_value(key: str, value: dict) -> None: + payload = json.dumps(value, ensure_ascii=False) + with _LOCK, _connect() as conn: + conn.execute( + "INSERT INTO config (key, value) VALUES (?, ?)" + " ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, payload), + ) diff --git a/backend/app/main.py b/backend/app/main.py index 5b8700a..033cf40 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles from . import db from .config import settings -from .routes import chat, files, models, sessions +from .routes import chat, config, files, models, sessions @asynccontextmanager @@ -40,6 +40,7 @@ app.include_router(models.router) app.include_router(sessions.router) app.include_router(chat.router) app.include_router(files.router) +app.include_router(config.router) @app.get("/api/health") diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index d63ad42..71b618b 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -14,26 +14,17 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from .. import db +from .. import agent_config, db from ..agent import run_agent from ..config import settings router = APIRouter(prefix="/api", tags=["chat"]) -SYSTEM_PROMPT = ( - "Tu es Loki, un assistant de développement local agentique. Tu disposes " - "d'outils pour lire, écrire et lister des fichiers dans le workspace. " - "Utilise-les pour accomplir les tâches concrètement, puis réponds de façon " - "concise en français. Après avoir écrit un fichier, propose un aperçu." -) - class ChatRequest(BaseModel): session_id: str content: str model: str | None = None - options: dict | None = None - tools_enabled: bool = True def _sse(event: str, data: dict) -> str: @@ -47,6 +38,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: raise HTTPException(404, "session introuvable") model = req.model or session.get("model") or settings.default_model + cfg = agent_config.get_config() # Premier message : titre la session avec un extrait. if not db.list_messages(req.session_id): @@ -55,7 +47,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: db.add_message(req.session_id, "user", req.content, None) - convo = [{"role": "system", "content": SYSTEM_PROMPT}] + convo = [{"role": "system", "content": cfg["system_prompt"]}] convo += db.list_messages_for_model(req.session_id) async def event_stream(): @@ -64,7 +56,10 @@ async def chat(req: ChatRequest) -> StreamingResponse: tools_meta: list[dict] = [] async for ev in run_agent( - model, convo, options=req.options, tools_enabled=req.tools_enabled + model, + convo, + options=agent_config.ollama_options(cfg), + enabled_tools=agent_config.enabled_tool_names(cfg), ): etype = ev.pop("type") if etype == "token": diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py new file mode 100644 index 0000000..9f11ce0 --- /dev/null +++ b/backend/app/routes/config.py @@ -0,0 +1,32 @@ +"""Routes de configuration de l'agent (lecture / mise à jour).""" +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import BaseModel + +from .. import agent_config + +router = APIRouter(prefix="/api/config", tags=["config"]) + + +class ConfigPatch(BaseModel): + system_prompt: str | None = None + temperature: float | None = None + top_p: float | None = None + top_k: int | None = None + max_tokens: int | None = None + tools: dict[str, bool] | None = None + + +@router.get("") +async def get_config() -> dict: + return { + "config": agent_config.get_config(), + "available_tools": agent_config.AVAILABLE_TOOLS, + } + + +@router.put("") +async def put_config(patch: ConfigPatch) -> dict: + cfg = agent_config.save_config(patch.model_dump(exclude_none=True)) + return {"config": cfg} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 277d7ab..799c70a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,15 +9,16 @@ import { useStore } from "./store/useStore"; export default function App() { const [view, setView] = useState("chat"); - const { refreshStatus, refreshModels } = useStore(); + const { refreshStatus, refreshModels, refreshConfig } = useStore(); - // Au démarrage : statut Ollama + liste des modèles. Poll du statut. + // Au démarrage : statut Ollama + modèles + config. Poll du statut. useEffect(() => { refreshStatus(); refreshModels(); + refreshConfig(); const id = setInterval(refreshStatus, 10000); return () => clearInterval(id); - }, [refreshStatus, refreshModels]); + }, [refreshStatus, refreshModels, refreshConfig]); return (
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 15f9c10..49e429c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -55,6 +55,34 @@ export interface Message { created_at: number; } +export interface AgentConfig { + system_prompt: string; + temperature: number; + top_p: number; + top_k: number; + max_tokens: number; + tools: Record; +} + +export async function getConfig(): Promise<{ + config: AgentConfig; + available_tools: string[]; +}> { + const res = await fetch("/api/config"); + return res.json(); +} + +export async function saveConfig( + patch: Partial +): Promise { + const res = await fetch("/api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + return (await res.json()).config; +} + export interface FileNode { name: string; path: string; diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index b94b86e..9dfa5f8 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -14,8 +14,13 @@ export function ChatPanel() { streamTools, sendMessage, currentSessionId, + config, } = useStore(); + const activeTools = config + ? Object.values(config.tools).filter(Boolean).length + : 0; + const [draft, setDraft] = useState(""); const scrollRef = useRef(null); @@ -45,10 +50,15 @@ export function ChatPanel() {
Invite système - 3 outils actifs + + {activeTools} outil{activeTools > 1 ? "s" : ""} actif + {activeTools > 1 ? "s" : ""} - Température 0.7 + Température{" "} + + {config ? config.temperature.toFixed(2) : "—"} +
diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index f61080e..f09b3fe 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -1,12 +1,39 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useStore } from "../store/useStore"; import { pullModel } from "../api/client"; +import type { AgentConfig } from "../api/client"; import { DownloadIcon, RefreshIcon } from "../components/Icon"; +const TOOL_DESC: Record = { + read_file: "Lire un fichier du projet", + write_file: "Créer / modifier un fichier", + list_dir: "Lister un répertoire", +}; + /** Vue Configuration (Frame 2) — Modèle & génération, outils, invite système. */ export function SettingsView() { - const { models, selectedModel, setSelectedModel, refreshModels, status } = - useStore(); + const { + models, + selectedModel, + setSelectedModel, + refreshModels, + status, + config, + availableTools, + refreshConfig, + updateConfig, + } = useStore(); + + // Brouillon local édité, synchronisé depuis la config serveur. + const [draft, setDraft] = useState(config); + const [saved, setSaved] = useState(false); + + useEffect(() => { + refreshConfig(); + }, [refreshConfig]); + useEffect(() => { + if (config) setDraft(config); + }, [config]); const [pullName, setPullName] = useState(""); const [pullStatus, setPullStatus] = useState(null); @@ -28,6 +55,23 @@ export function SettingsView() { } }; + const dirty = + draft && config && JSON.stringify(draft) !== JSON.stringify(config); + + const save = async () => { + if (!draft) return; + await updateConfig(draft); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + }; + + const set = (key: K, value: AgentConfig[K]) => + setDraft((d) => (d ? { ...d, [key]: value } : d)); + + const activeTools = draft + ? Object.values(draft.tools).filter(Boolean).length + : 0; + return (
@@ -41,199 +85,243 @@ export function SettingsView() { Choisis le modèle local et règle son comportement.
-
- - {status?.connected ? "Ollama connecté" : "Ollama déconnecté"} +
+
+ + {status?.connected ? "Ollama connecté" : "Ollama déconnecté"} +
+
-
- {/* Modèle Ollama */} -
-
-
Modèle Ollama
- -
- -
- {models.length === 0 && ( -
- Aucun modèle installé. Télécharge-en un ci-dessous. -
- )} - {models.map((m) => { - const on = m.name === selectedModel; - return ( + {!draft ? ( +
+ Chargement de la configuration… +
+ ) : ( + <> +
+ {/* Modèle Ollama */} +
+
+
Modèle Ollama
- ); - })} -
- -
- -
- Télécharger un modèle -
-
- setPullName(e.target.value)} - placeholder="gemma2:9b" - className="h-[34px] flex-1 rounded-[9px] border border-line-strong bg-base px-3 font-mono text-xs text-ink outline-none placeholder:text-muted-3" - /> - -
- - {pullStatus && ( -
-
- - {pullName} - - - {pullStatus} {pullPct ? `· ${pullPct}%` : ""} -
-
-
+ {models.length === 0 && ( +
+ Aucun modèle installé. Télécharge-en un ci-dessous. +
+ )} + {models.map((m) => { + const on = m.name === selectedModel; + return ( + + ); + })} +
+ +
+ +
+ Télécharger un modèle +
+
+ setPullName(e.target.value)} + placeholder="gemma2:9b" + className="h-[34px] flex-1 rounded-[9px] border border-line-strong bg-base px-3 font-mono text-xs text-ink outline-none placeholder:text-muted-3" + /> + +
+ + {pullStatus && ( +
+
+ + {pullName} + + + {pullStatus} {pullPct ? `· ${pullPct}%` : ""} + +
+
+
+
+
+ )} +
+ + {/* Génération + Outils */} +
+
+
Génération
+ v.toFixed(2)} + onChange={(v) => set("temperature", v)} + /> + v.toFixed(2)} + onChange={(v) => set("top_p", v)} + /> + String(Math.round(v))} + onChange={(v) => set("top_k", Math.round(v))} + /> + String(Math.round(v))} + onChange={(v) => set("max_tokens", Math.round(v))} + last />
-
- )} -
- {/* Génération + Outils */} -
-
-
Génération
- - - - +
+
+
Outils de l'agent
+ + {activeTools} / {availableTools.length} actifs + +
+ {availableTools.map((name, i) => { + const on = draft.tools[name] ?? false; + return ( +
0 ? "border-t border-line-soft" : "" + }`} + > + + {name} + + + {TOOL_DESC[name] ?? ""} + + +
+ ); + })} +
+ web_search · run_shell — arrivent bientôt (sensibles) +
+
+
-
-
-
Outils de l'agent
+ {/* Invite système */} +
+
+
Invite système
- bientôt + {draft.system_prompt.length} caractères
- {[ - ["read_file", "Lire un fichier du projet", true], - ["write_file", "Créer / modifier un fichier", true], - ["list_dir", "Lister un répertoire", true], - ["html_preview", "Aperçu HTML en direct", true], - ["web_search", "Recherche web", false], - ["run_shell", "Exécuter une commande · sensible", false], - ].map(([name, desc, on], i) => ( -
0 ? "border-t border-line-soft" : "" - }`} - > - - {name} - - {desc} - - - -
- ))} +