diff --git a/Dockerfile b/Dockerfile index b4d7ec1..3c08f06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,8 @@ ENV PYTHONUNBUFFERED=1 \ DATA_DIR=/data \ PORT=8717 -# curl pour le HEALTHCHECK -RUN apt-get update && apt-get install -y --no-install-recommends curl \ +# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider) +RUN apt-get update && apt-get install -y --no-install-recommends curl git \ && rm -rf /var/lib/apt/lists/* COPY backend/requirements.txt ./ diff --git a/README.md b/README.md index 9be1ed0..190b71a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,24 @@ npm run dev # http://localhost:5173 | `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | | `run_shell` | Exécuter une commande **(sensible)** | désactivé | +## Moteur code (façon Claude Code) — routage automatique + +Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme +**moteur code** : édition multi-fichiers fiable (formats diff/search-replace, +efficaces même avec de petits modèles), repo map, et **commits git +automatiques** dans le workspace. + +**C'est invisible** : un routeur classe chaque message. +- Tâche de code détectée (heuristique + micro-classification LLM sur les cas + ambigus) → le message part au **moteur code** ; le fil affiche la carte + `code_task`, les fichiers modifiés et le commit. +- Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au + moteur via l'outil `code_task` quand il juge qu'il faut coder. + +Désactivable dans Configuration → Outils (`code_task`). Le workspace est +auto-initialisé en dépôt git : chaque modification de code = un commit +(historique et rollback via `git log` / `git revert` dans le workspace). + ## Profil GPU fourni Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 : diff --git a/backend/app/agent.py b/backend/app/agent.py index d03d4f2..15fbf5c 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -13,11 +13,13 @@ relayés tels quels au client via SSE. """ from __future__ import annotations +import asyncio import json from typing import AsyncIterator import httpx +from . import coder from .ollama_client import OllamaError, ollama from .tools import TOOL_DEFINITIONS, ToolError, run_tool @@ -260,6 +262,30 @@ async def run_agent( awaiting_confirmation = True continue + # code_task : délégué au moteur code (Aider), long -> thread. + if name == "code_task": + result = await asyncio.to_thread( + coder.run_code_task, + args.get("instruction", ""), + model, + args.get("files") or [], + ) + summary = result.get("summary", "terminé") + status = "ok" if result.get("ok") else "error" + record = {"name": name, "args": {"instruction": args.get("instruction", "")}, + "summary": summary, "status": status} + collected.append(record) + yield {"type": "tool_result", **record} + convo.append({ + "role": "tool", + "tool_name": name, + "content": json.dumps( + {k: result.get(k) for k in ("ok", "summary", "files", "text")}, + ensure_ascii=False, + ), + }) + continue + try: result = run_tool(name, args) summary = result.get("summary", "terminé") diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py index 33d1822..12542cd 100644 --- a/backend/app/agent_config.py +++ b/backend/app/agent_config.py @@ -14,12 +14,14 @@ PROFILE_VERSION = 5 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. Quand tu appelles write_file, fournis toujours " - "`path` et `content`. Pour un fichier long, appelle write_file en plusieurs " - "morceaux (overwrite puis append) afin de toujours produire un JSON valide. " - "Après avoir écrit un fichier, propose un aperçu. " + "d'outils pour lire, écrire et lister des fichiers dans le workspace, et " + "d'un moteur code (code_task) pour toute création ou modification de code " + "multi-fichiers : privilégie code_task pour les tâches de programmation. " + "Utilise les outils pour accomplir les tâches concrètement, puis réponds " + "de façon concise en français. Quand tu appelles write_file, fournis " + "toujours `path` et `content`. Pour un fichier long, appelle write_file en " + "plusieurs morceaux (overwrite puis append) afin de toujours produire un " + "JSON valide. Après avoir écrit un fichier, propose un aperçu. " "Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les " "points clés, tableaux si pertinent, et surtout des blocs de code avec le " "langage indiqué (```python, ```html…) pour tout extrait de code ou commande." @@ -27,12 +29,15 @@ DEFAULT_SYSTEM_PROMPT = ( # Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés # par défaut, conformément à la maquette. -AVAILABLE_TOOLS = ["read_file", "write_file", "list_dir", "web_search", "run_shell"] +AVAILABLE_TOOLS = [ + "read_file", "write_file", "list_dir", "code_task", "web_search", "run_shell", +] SENSITIVE_TOOLS = {"run_shell"} DEFAULT_TOOL_STATE = { "read_file": True, "write_file": True, "list_dir": True, + "code_task": True, "web_search": False, "run_shell": False, } diff --git a/backend/app/coder.py b/backend/app/coder.py new file mode 100644 index 0000000..734b3da --- /dev/null +++ b/backend/app/coder.py @@ -0,0 +1,106 @@ +"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable. + +Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition +diff/search-replace robustes (même avec de petits modèles), repo map, et +commits git automatiques dans le workspace. + +L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE +dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la +fonction (l'app démarre même si Aider est absent). +""" +from __future__ import annotations + +import os +import subprocess +import threading + +from .config import settings + +# Aider utilise le répertoire courant : un seul run à la fois. +_RUN_LOCK = threading.Lock() + + +def ensure_git(root: str) -> None: + """Initialise un dépôt git dans le workspace (requis pour les commits Aider).""" + os.makedirs(root, exist_ok=True) + if os.path.isdir(os.path.join(root, ".git")): + return + subprocess.run(["git", "init", "-q"], cwd=root, check=False) + subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False) + subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False) + + +def available() -> bool: + """Aider est-il installé ?""" + try: + import aider # noqa: F401 + return True + except ImportError: + return False + + +def run_code_task( + instruction: str, + model: str, + files: list[str] | None = None, +) -> dict: + """Exécute une tâche de code via Aider (synchrone — lancer dans un thread). + + Renvoie {ok, text, files, commit, commit_message, summary}. + """ + if not available(): + return { + "ok": False, + "summary": "moteur code indisponible (aider non installé)", + "text": "", "files": [], "commit": None, + } + + from aider.coders import Coder + from aider.io import InputOutput + from aider.models import Model + + root = os.path.abspath(settings.workspace_dir) + ensure_git(root) + + # Aider parle à Ollama via litellm : on pointe vers notre instance. + os.environ["OLLAMA_API_BASE"] = settings.ollama_host + + with _RUN_LOCK: + prev_cwd = os.getcwd() + os.chdir(root) + try: + io = InputOutput(yes=True, pretty=False, fancy_input=False) + coder = Coder.create( + main_model=Model(f"ollama_chat/{model}"), + io=io, + fnames=[os.path.join(root, f) for f in (files or [])], + auto_commits=True, + stream=False, + use_git=True, + suggest_shell_commands=False, + detect_urls=False, + ) + text = coder.run(instruction) or "" + edited = sorted(coder.aider_edited_files or []) + commit = getattr(coder, "last_aider_commit_hash", None) + commit_msg = getattr(coder, "last_aider_commit_message", None) + except Exception as exc: # aider peut lever des erreurs variées + return { + "ok": False, + "summary": f"échec moteur code : {exc}", + "text": "", "files": [], "commit": None, + } + finally: + os.chdir(prev_cwd) + + summary = f"{len(edited)} fichier(s) modifié(s)" + if commit: + summary += f" · commit {str(commit)[:7]}" + return { + "ok": True, + "text": text.strip(), + "files": edited, + "commit": commit, + "commit_message": commit_msg, + "summary": summary, + } diff --git a/backend/app/main.py b/backend/app/main.py index 20a97ec..de4f301 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,7 +12,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from . import db +from . import coder, db from .config import settings from .routes import chat, config, files, models, sessions, shell, system @@ -20,6 +20,8 @@ from .routes import chat, config, files, models, sessions, shell, system @asynccontextmanager async def lifespan(_: FastAPI): db.init_db() + # Workspace en dépôt git : requis pour les commits du moteur code (Aider). + coder.ensure_git(settings.workspace_dir) yield diff --git a/backend/app/router.py b/backend/app/router.py new file mode 100644 index 0000000..4eb60ce --- /dev/null +++ b/backend/app/router.py @@ -0,0 +1,81 @@ +"""Routeur automatique : moteur code (Aider) ou boucle agent classique ? + +Invisible pour l'utilisateur : chaque message est classé. + 1. Heuristique lexicale rapide (gratuite) — tranche les cas évidents. + 2. Cas ambigus : micro-classification par le modèle lui-même (3 jetons). +""" +from __future__ import annotations + +import re + +import httpx + +from .ollama_client import ollama + +# Vocabulaire fortement lié au code / au développement. +_STRONG = re.compile( + r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|" + r"javascript|js|python|typescript|react|api|composant|page|site|landing|" + r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b", + re.I, +) +# Extensions de fichiers mentionnées explicitement. +_FILE_EXT = re.compile( + r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I +) +# Verbes d'action de création/modification. +_ACTION = re.compile( + r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|" + r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b", + re.I, +) + + +def score_code_task(message: str) -> int: + """Score heuristique : >= 3 -> code, <= 1 -> agent, 2 -> ambigu.""" + score = 0 + if _STRONG.search(message): + score += 2 + if _FILE_EXT.search(message): + score += 2 + if _ACTION.search(message): + score += 1 + if "```" in message: + score += 2 + return score + + +async def is_code_task(message: str, model: str) -> bool: + """Décision finale : heuristique, puis LLM sur les cas ambigus.""" + score = score_code_task(message) + if score >= 3: + return True + if score <= 1: + return False + + # Ambigu : on demande au modèle (réponse d'un mot, quasi instantané). + try: + answer = "" + async for chunk in ollama.chat( + model, + [ + { + "role": "system", + "content": ( + "Tu classes des demandes. Réponds UNIQUEMENT par CODE " + "si la demande nécessite d'écrire ou modifier du code " + "ou des fichiers de projet, sinon CHAT." + ), + }, + {"role": "user", "content": message[:500]}, + ], + options={"num_predict": 4, "temperature": 0}, + stream=True, + ): + answer += chunk.get("message", {}).get("content", "") + if chunk.get("done"): + break + return "CODE" in answer.upper() + except (httpx.HTTPError, OSError): + # Ollama indisponible : la boucle agent gérera l'erreur proprement. + return False diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index af245cf..77c55fa 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -1,8 +1,9 @@ -"""Route de conversation agentique : boucle d'outils + streaming SSE. +"""Route de conversation : routage automatique agent / moteur code + SSE. Flux : 1. on enregistre le message utilisateur ; - 2. on lance la boucle agentique (run_agent) sur l'historique de la session ; + 2. le routeur classe la demande : tâche de code -> moteur code (Aider), + sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ; 3. on relaie tokens et événements d'outils au client (SSE) ; 4. on enregistre la réponse finale de l'assistant + le récap des outils. """ @@ -17,7 +18,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from .. import agent_config, db +from .. import agent_config, coder, db, router as msg_router from ..agent import run_agent from ..config import settings @@ -35,6 +36,56 @@ def _sse(event: str, data: dict) -> str: return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" +async def _code_stream(req: ChatRequest, model: str): + """Chemin « moteur code » : Aider travaille, on garde le flux SSE vivant.""" + instruction = req.content + yield _sse("tool_call", {"name": "code_task", "args": {"instruction": instruction}}) + + task = asyncio.create_task( + asyncio.to_thread(coder.run_code_task, instruction, model, None) + ) + # Keepalive SSE pendant le travail (peut durer plusieurs minutes). + while not task.done(): + await asyncio.sleep(10) + if not task.done(): + yield ": keepalive\n\n" + result = await task + + status = "ok" if result.get("ok") else "error" + record = { + "name": "code_task", + "args": {"instruction": instruction}, + "summary": result.get("summary", "terminé"), + "status": status, + } + tools_meta = [record] + yield _sse("tool_result", record) + + # Cartes par fichier modifié (réutilise le rendu write_file de l'UI). + for f in result.get("files") or []: + file_rec = { + "name": "write_file", + "args": {"path": f}, + "summary": "modifié par le moteur code", + "status": "ok", + } + tools_meta.append(file_rec) + yield _sse("tool_call", {"name": "write_file", "args": {"path": f}}) + yield _sse("tool_result", file_rec) + + text = result.get("text") or ( + "" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}" + ) + if text: + yield _sse("token", {"content": text}) + + db.add_message( + req.session_id, "assistant", text, model, + meta={"tools": tools_meta, "engine": "code"}, + ) + yield _sse("done", {"content": text, "tools": tools_meta}) + + @router.post("/chat") async def chat(req: ChatRequest) -> StreamingResponse: session = db.get_session(req.session_id) @@ -51,11 +102,26 @@ async def chat(req: ChatRequest) -> StreamingResponse: db.add_message(req.session_id, "user", req.content, None) + # Routage automatique : moteur code si la demande est une tâche de code, + # que l'outil est actif et qu'Aider est disponible. + use_code = ( + cfg["tools"].get("code_task", True) + and coder.available() + and await msg_router.is_code_task(req.content, model) + ) + convo = [{"role": "system", "content": cfg["system_prompt"]}] convo += db.list_messages_for_model(req.session_id) async def event_stream(): - yield _sse("start", {"model": model}) + yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"}) + + # Chemin « moteur code » : Aider gère la tâche de bout en bout. + if use_code: + async for chunk in _code_stream(req, model): + yield chunk + return + final_content = "" tools_meta: list[dict] = [] stats_meta: dict | None = None diff --git a/backend/app/tools.py b/backend/app/tools.py index 7d60f38..75cac6e 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -241,6 +241,32 @@ TOOL_DEFINITIONS = [ }, }, }, + { + "type": "function", + "function": { + "name": "code_task", + "description": ( + "Confier une tâche de programmation au moteur code (édition " + "multi-fichiers fiable, commits git). À utiliser pour créer ou " + "modifier du code." + ), + "parameters": { + "type": "object", + "properties": { + "instruction": { + "type": "string", + "description": "La tâche de code, précise et complète", + }, + "files": { + "type": "array", + "items": {"type": "string"}, + "description": "Fichiers concernés (optionnel)", + }, + }, + "required": ["instruction"], + }, + }, + }, { "type": "function", "function": { diff --git a/backend/requirements.txt b/backend/requirements.txt index f2980fe..004130a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,6 @@ httpx==0.28.1 pydantic==2.10.4 pydantic-settings==2.7.1 psutil==6.1.1 +# Moteur code (édition multi-fichiers façon Claude Code). Version FIGÉE : +# l'API Python d'Aider n'est pas garantie stable entre versions. +aider-chat==0.86.2 diff --git a/frontend/src/components/ToolCard.tsx b/frontend/src/components/ToolCard.tsx index fa841f9..dc0bedd 100644 --- a/frontend/src/components/ToolCard.tsx +++ b/frontend/src/components/ToolCard.tsx @@ -10,7 +10,8 @@ export function ToolCard({ call }: { call: ToolCall }) { const mainArg = (call.args?.path as string) ?? (call.args?.query as string) ?? - (call.args?.command as string); + (call.args?.command as string) ?? + (call.args?.instruction as string); const argPreview = typeof mainArg === "string" ? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")` @@ -82,6 +83,12 @@ function ToolGlyph({ name }: { name: string }) { ); + if (name === "code_task") + return ( + + + + ); if (name === "web_search") return ( diff --git a/frontend/src/panels/SettingsView.tsx b/frontend/src/panels/SettingsView.tsx index 5ad307e..fbc5623 100644 --- a/frontend/src/panels/SettingsView.tsx +++ b/frontend/src/panels/SettingsView.tsx @@ -8,6 +8,7 @@ const TOOL_DESC: Record = { read_file: "Lire un fichier du projet", write_file: "Créer / modifier un fichier", list_dir: "Lister un répertoire", + code_task: "Moteur code (multi-fichiers, git)", web_search: "Recherche web", run_shell: "Exécuter une commande", };