mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Moteur code façon Claude Code : Aider intégré avec routage automatique
- coder.py : enveloppe Aider (version figée 0.86.2), commits git auto dans le workspace, verrou d'exécution, dégradation propre si absent - router.py : classification automatique de chaque message (heuristique lexicale + micro-classification LLM sur les cas ambigus) — invisible pour l'utilisateur - routes/chat.py : routage code/agent, chemin code avec keepalive SSE, cartes fichier + commit dans le fil, meta.engine persisté - agent.py : outil code_task exécuté en thread — l'agent peut coder lui-même - tools/agent_config : code_task enregistré (actif par défaut), invite système mise à jour - main.py : workspace auto-initialisé en dépôt git au démarrage - Dockerfile : git ; requirements : aider-chat==0.86.2 - UI : glyphe code_task, description outil, aperçu d'instruction Tests : heuristiques 7/7, routage HTTP code+agent de bout en bout (SSE, meta persistée), agent appelant code_task, build front. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
12 files changed
+356
-15
No files matched your search
+2
-2
@@ -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 ./
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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é")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+3
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
@@ -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 }) {
|
||||
<path d="M4 7h6l2 2h8v9H4z" />
|
||||
</svg>
|
||||
);
|
||||
if (name === "code_task")
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
|
||||
</svg>
|
||||
);
|
||||
if (name === "web_search")
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -8,6 +8,7 @@ const TOOL_DESC: Record<string, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user