mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Les 5 évolutions : plan, auto-critique, RAG, vérif HTML, benchmark
1. Plan-puis-exécute (enhance.make_plan) : les demandes complexes sont décomposées en 3-5 étapes (event SSE 'plan', carte PLAN dans le fil, meta.plan persisté) ; le plan guide l'agent et le moteur code. 2. Auto-critique « Qualité + » (enhance.self_review, toggle Intelligence) : critique éclair puis révision de la réponse (event 'revision'). 3. Mémoire long-terme RAG (rag.py) : échanges vectorisés via /api/embed (modèle d'embedding auto-détecté), rappel cosinus top-3 inter-sessions injecté en contexte, indexation en arrière-plan, élagage à 2000 souvenirs. 4. Vérification HTML (tools.check_html) : références locales cassées et balises déséquilibrées ; branchée sur l'auto-vérification des outils ET sur le moteur code avec une passe d'auto-correction Aider. 5. Benchmark intégré (bench.py + /api/bench) : 5 épreuves notées /100 (appel d'outil, code exécuté en sous-processus isolé, consignes, JSON, format), streaming SSE, scores stockés ; carte BENCHMARK dans l'UI. Config : plan_mode / self_review / rag_enabled / embed_model + carte Intelligence (3 toggles). Client SSE : events plan/revision ; PlanCard. Tests : heuristique+parsing du plan, révision, index/rappel RAG (exclusion de la session courante), html_check, bench 100/100 sur modèle simulé, intégration chat HTTP (event plan + meta persisté), 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:
15 files changed
+960
-24
No files matched your search
@@ -115,6 +115,23 @@ npm run dev # http://localhost:5173
|
||||
| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé |
|
||||
| `run_shell` | Exécuter une commande **(sensible)** | désactivé |
|
||||
|
||||
## Intelligence augmentée
|
||||
|
||||
- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes
|
||||
affichées dans le fil ; l'agent (ou le moteur code) suit le plan.
|
||||
- **Auto-critique « Qualité + »** (Configuration → Intelligence) : la réponse
|
||||
est relue et révisée avant d'être finalisée.
|
||||
- **Mémoire long-terme (RAG)** : chaque échange est vectorisé (`/api/embed`)
|
||||
et les souvenirs pertinents des anciennes sessions sont réinjectés en
|
||||
contexte. Nécessite un modèle d'embedding installé (ex.
|
||||
`ollama pull nomic-embed-text`) — sinon désactivé silencieusement.
|
||||
- **Vérification HTML** : liens locaux cassés et balises déséquilibrées sont
|
||||
détectés après chaque génération ; le moteur code fait une passe
|
||||
d'auto-correction.
|
||||
- **Benchmark intégré** (Configuration → Benchmark) : 5 mini-épreuves notées
|
||||
/100 (appel d'outil, code exécutable, consignes, extraction JSON, format)
|
||||
pour comparer objectivement tes modèles installés.
|
||||
|
||||
## Tirer le meilleur des petits modèles
|
||||
|
||||
Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent :
|
||||
|
||||
@@ -60,6 +60,10 @@ PROFILE_FIELDS = {
|
||||
"confirm_shell",
|
||||
"think",
|
||||
"code_model",
|
||||
"plan_mode",
|
||||
"self_review",
|
||||
"rag_enabled",
|
||||
"embed_model",
|
||||
*GENERATION_FIELDS,
|
||||
}
|
||||
|
||||
@@ -95,6 +99,13 @@ DEFAULT_CONFIG: dict = {
|
||||
# Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé
|
||||
# (qwen-coder, deepseek-coder…), sinon le modèle de chat courant.
|
||||
"code_model": "auto",
|
||||
# Plan-puis-exécute : décompose les demandes complexes en étapes.
|
||||
"plan_mode": True,
|
||||
# Auto-critique : une passe de relecture/révision avant la réponse finale.
|
||||
"self_review": False,
|
||||
# Mémoire long-terme (RAG) entre sessions, via un modèle d'embedding.
|
||||
"rag_enabled": True,
|
||||
"embed_model": "auto",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Benchmark intégré : évalue objectivement chaque modèle installé.
|
||||
|
||||
Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki :
|
||||
appel d'outil, code exécutable, respect des consignes, extraction JSON,
|
||||
respect d'un format. Score /100, stocké en base et affiché dans l'UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from . import db
|
||||
from .ollama_client import ollama
|
||||
|
||||
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
|
||||
|
||||
|
||||
async def _ask(model: str, prompt: str, *, system: str = "",
|
||||
tools: list | None = None, num_predict: int = 400) -> dict:
|
||||
"""Un appel modèle ; renvoie {text, tool_calls}."""
|
||||
messages = []
|
||||
if system:
|
||||
messages.append({"role": "system", "content": system})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
text, calls = "", []
|
||||
async for chunk in ollama.chat(
|
||||
model, messages, tools=tools,
|
||||
options={"temperature": 0, "num_predict": num_predict}, stream=True,
|
||||
):
|
||||
msg = chunk.get("message", {})
|
||||
text += msg.get("content", "")
|
||||
if msg.get("tool_calls"):
|
||||
calls.extend(msg["tool_calls"])
|
||||
if chunk.get("done"):
|
||||
break
|
||||
return {"text": text.strip(), "tool_calls": calls}
|
||||
|
||||
|
||||
def _extract_code(text: str) -> str:
|
||||
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S)
|
||||
return (m.group(1) if m else text).strip()
|
||||
|
||||
|
||||
def _run_python(code: str, test: str) -> bool:
|
||||
"""Exécute code+test dans un sous-processus isolé (timeout 8 s)."""
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
|
||||
f.write(code + "\n" + test)
|
||||
path = f.name
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-I", path],
|
||||
capture_output=True, timeout=8,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ── Les 5 épreuves (score 0-20 chacune) ──────────────────────────────────
|
||||
async def _task_tool_call(model: str) -> tuple[int, str]:
|
||||
tools = [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "write_file",
|
||||
"description": "Écrire un fichier",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
},
|
||||
"required": ["path", "content"],
|
||||
},
|
||||
},
|
||||
}]
|
||||
try:
|
||||
r = await _ask(
|
||||
model,
|
||||
"Crée le fichier bonjour.txt contenant exactement le texte : salut",
|
||||
system="Utilise l'outil write_file pour créer le fichier demandé.",
|
||||
tools=tools, num_predict=200,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if "does not support tools" in exc.response.text.lower():
|
||||
return 0, "outils non supportés par ce modèle"
|
||||
raise
|
||||
for tc in r["tool_calls"]:
|
||||
fn = tc.get("function", {})
|
||||
if fn.get("name") == "write_file":
|
||||
args = fn.get("arguments") or {}
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
return 8, "appel d'outil aux arguments illisibles"
|
||||
ok_path = "bonjour" in str(args.get("path", "")).lower()
|
||||
ok_content = "salut" in str(args.get("content", "")).lower()
|
||||
score = 10 + 5 * ok_path + 5 * ok_content
|
||||
return score, "appel d'outil correct" if score == 20 else "appel partiel"
|
||||
return 0, "aucun appel d'outil émis"
|
||||
|
||||
|
||||
async def _task_code(model: str) -> tuple[int, str]:
|
||||
r = await _ask(
|
||||
model,
|
||||
"Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme "
|
||||
"des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.",
|
||||
num_predict=300,
|
||||
)
|
||||
code = _extract_code(r["text"])
|
||||
if "def somme_pairs" not in code:
|
||||
return 0, "fonction absente"
|
||||
test = (
|
||||
"assert somme_pairs([1,2,3,4]) == 6\n"
|
||||
"assert somme_pairs([]) == 0\n"
|
||||
"assert somme_pairs([7,9]) == 0\n"
|
||||
)
|
||||
return (20, "code correct (3/3 tests)") if _run_python(code, test) \
|
||||
else (6, "code présent mais tests échoués")
|
||||
|
||||
|
||||
async def _task_instruction(model: str) -> tuple[int, str]:
|
||||
r = await _ask(
|
||||
model,
|
||||
"Quelle est la capitale de la France ? Réponds en 3 mots maximum.",
|
||||
num_predict=30,
|
||||
)
|
||||
text = r["text"]
|
||||
has_answer = "paris" in text.lower()
|
||||
short = len(text.split()) <= 6
|
||||
score = 12 * has_answer + 8 * short
|
||||
return score, f"réponse « {text[:40]} »"
|
||||
|
||||
|
||||
async def _task_json(model: str) -> tuple[int, str]:
|
||||
r = await _ask(
|
||||
model,
|
||||
'Extrait les informations en JSON strict {"nom": ..., "ville": ...} '
|
||||
"depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.",
|
||||
num_predict=80,
|
||||
)
|
||||
m = re.search(r"\{.*\}", r["text"], re.S)
|
||||
if not m:
|
||||
return 0, "pas de JSON"
|
||||
try:
|
||||
data = json.loads(m.group(0))
|
||||
except json.JSONDecodeError:
|
||||
return 5, "JSON invalide"
|
||||
ok_nom = "marie" in str(data.get("nom", "")).lower()
|
||||
ok_ville = "lyon" in str(data.get("ville", "")).lower()
|
||||
return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \
|
||||
if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle")
|
||||
|
||||
|
||||
async def _task_format(model: str) -> tuple[int, str]:
|
||||
r = await _ask(
|
||||
model,
|
||||
"Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».",
|
||||
num_predict=60,
|
||||
)
|
||||
lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")]
|
||||
if len(lines) == 3:
|
||||
return 20, "format exact"
|
||||
if len(lines) >= 2:
|
||||
return 10, f"{len(lines)} lignes au lieu de 3"
|
||||
return 0, "format non respecté"
|
||||
|
||||
|
||||
TASKS = [
|
||||
("Appel d'outil", _task_tool_call),
|
||||
("Code exécutable", _task_code),
|
||||
("Consigne courte", _task_instruction),
|
||||
("Extraction JSON", _task_json),
|
||||
("Respect du format", _task_format),
|
||||
]
|
||||
|
||||
|
||||
async def run_bench(model: str) -> AsyncIterator[dict]:
|
||||
"""Exécute les 5 épreuves en streamant la progression, stocke le score."""
|
||||
total = 0
|
||||
details = []
|
||||
for name, fn in TASKS:
|
||||
yield {"type": "task_start", "task": name}
|
||||
try:
|
||||
score, detail = await fn(model)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
score, detail = 0, f"erreur : {str(exc)[:80]}"
|
||||
total += score
|
||||
details.append({"task": name, "score": score, "detail": detail})
|
||||
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
|
||||
|
||||
results = db.get_config_value(BENCH_KEY) or {}
|
||||
results[model] = {"score": total, "details": details, "at": time.time()}
|
||||
db.set_config_value(BENCH_KEY, results)
|
||||
yield {"type": "done", "score": total, "details": details}
|
||||
|
||||
|
||||
def get_scores() -> dict:
|
||||
return db.get_config_value(BENCH_KEY) or {}
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Boosters de qualité pour petits modèles : plan-puis-exécute et auto-critique.
|
||||
|
||||
- make_plan : décompose une demande complexe en 3-5 étapes courtes. Un petit
|
||||
modèle qui suit un plan écrit réussit bien mieux qu'en improvisant.
|
||||
- self_review : une passe de critique éclair sur la réponse, puis une révision
|
||||
si des défauts sont trouvés (activable : coûte un peu de latence).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import httpx
|
||||
|
||||
from .ollama_client import ollama
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLAN_PROMPT = (
|
||||
"Découpe la demande en 3 à 5 étapes courtes et concrètes, une par ligne, "
|
||||
"numérotées « 1. », « 2. »… Pas d'introduction, pas de conclusion, "
|
||||
"UNIQUEMENT les étapes, en français."
|
||||
)
|
||||
|
||||
_CRITIQUE_PROMPT = (
|
||||
"Tu es un relecteur exigeant. Voici une demande et la réponse d'un "
|
||||
"assistant. Si la réponse est correcte et complète, réponds exactement "
|
||||
"PARFAIT. Sinon, liste au plus 3 défauts concrets (erreurs, oublis, "
|
||||
"incohérences), un par ligne."
|
||||
)
|
||||
|
||||
_REVISE_PROMPT = (
|
||||
"Réécris la réponse en corrigeant les défauts listés. Donne UNIQUEMENT la "
|
||||
"réponse finale corrigée, sans commentaire sur la révision."
|
||||
)
|
||||
|
||||
|
||||
def needs_plan(message: str) -> bool:
|
||||
"""Une demande assez longue/composée mérite un plan explicite."""
|
||||
if len(message) < 120:
|
||||
return False
|
||||
connectors = len(re.findall(
|
||||
r"\b(puis|ensuite|après|avec|ainsi que|et aussi|également)\b",
|
||||
message, re.I,
|
||||
))
|
||||
return len(message) > 240 or connectors >= 2
|
||||
|
||||
|
||||
async def _ask(model: str, system: str, user: str, *, num_predict: int) -> str:
|
||||
text = ""
|
||||
async for chunk in ollama.chat(
|
||||
model,
|
||||
[{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}],
|
||||
options={"temperature": 0.2, "num_predict": num_predict},
|
||||
stream=True,
|
||||
):
|
||||
text += chunk.get("message", {}).get("content", "")
|
||||
if chunk.get("done"):
|
||||
break
|
||||
return text.strip()
|
||||
|
||||
|
||||
async def make_plan(model: str, message: str) -> list[str]:
|
||||
"""Renvoie la liste des étapes (vide si échec — jamais bloquant)."""
|
||||
try:
|
||||
raw = await _ask(model, _PLAN_PROMPT, message[:1200], num_predict=220)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Plan impossible : %s", exc)
|
||||
return []
|
||||
steps = []
|
||||
for line in raw.splitlines():
|
||||
line = line.strip()
|
||||
m = re.match(r"^\d+[.)]\s*(.+)$", line)
|
||||
if m:
|
||||
steps.append(m.group(1).strip())
|
||||
return steps[:5] if len(steps) >= 2 else []
|
||||
|
||||
|
||||
async def self_review(model: str, request: str, answer: str) -> str | None:
|
||||
"""Critique puis révise la réponse. None si rien à corriger / échec."""
|
||||
if len(answer) < 80:
|
||||
return None
|
||||
try:
|
||||
critique = await _ask(
|
||||
model,
|
||||
_CRITIQUE_PROMPT,
|
||||
f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}",
|
||||
num_predict=180,
|
||||
)
|
||||
if not critique or "PARFAIT" in critique.upper()[:40]:
|
||||
return None
|
||||
|
||||
revised = await _ask(
|
||||
model,
|
||||
_REVISE_PROMPT,
|
||||
f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}"
|
||||
f"\n\nDéfauts :\n{critique[:600]}",
|
||||
num_predict=1500,
|
||||
)
|
||||
# Garde-fou : une révision vide ou minuscule ne remplace rien.
|
||||
return revised if len(revised) > len(answer) // 3 else None
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Auto-critique impossible : %s", exc)
|
||||
return None
|
||||
+4
-2
@@ -12,14 +12,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import coder, db
|
||||
from . import coder, db, rag
|
||||
from .config import settings
|
||||
from .routes import chat, config, files, models, sessions, shell, system
|
||||
from .routes import benchmark, chat, config, files, models, sessions, shell, system
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
db.init_db()
|
||||
rag.init_table()
|
||||
# Workspace en dépôt git : requis pour les commits du moteur code (Aider).
|
||||
coder.ensure_git(settings.workspace_dir)
|
||||
yield
|
||||
@@ -45,6 +46,7 @@ app.include_router(files.router)
|
||||
app.include_router(config.router)
|
||||
app.include_router(shell.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(benchmark.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -65,6 +65,15 @@ class OllamaClient:
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("models", [])
|
||||
|
||||
async def embed(self, model: str, texts: list[str]) -> list[list[float]]:
|
||||
"""Vecteurs d'embedding pour une liste de textes (/api/embed)."""
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = await client.post(
|
||||
f"{self.host}/api/embed", json={"model": model, "input": texts}
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("embeddings", [])
|
||||
|
||||
async def ps(self) -> list[dict]:
|
||||
"""Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps)."""
|
||||
async with httpx.AsyncClient(timeout=5.0, follow_redirects=True) as client:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Mémoire long-terme (RAG) : l'agent se souvient des anciennes sessions.
|
||||
|
||||
Chaque échange (question + réponse) est vectorisé via /api/embed d'Ollama et
|
||||
stocké en SQLite. À chaque nouveau message, on recherche les souvenirs les
|
||||
plus proches (cosinus) dans les AUTRES sessions et on les injecte en contexte.
|
||||
|
||||
Tout est best-effort : sans modèle d'embedding installé, le RAG se désactive
|
||||
silencieusement (aucun impact sur le chat).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from . import db
|
||||
from .ollama_client import ollama
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Modèles d'embedding reconnus, par ordre de préférence.
|
||||
_EMBED_HINTS = ("nomic-embed", "mxbai-embed", "bge-", "snowflake-arctic-embed",
|
||||
"all-minilm", "embed")
|
||||
|
||||
_TOP_K = 3
|
||||
_MIN_SCORE = 0.45
|
||||
_MAX_MEMORIES = 2000 # au-delà, on élague les plus anciens
|
||||
|
||||
_embed_model_cache: dict = {"value": None, "checked_at": 0.0}
|
||||
|
||||
|
||||
def init_table() -> None:
|
||||
with db._LOCK, db._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def resolve_embed_model(preference: str | None = None) -> str | None:
|
||||
"""Trouve le modèle d'embedding à utiliser (None = RAG indisponible)."""
|
||||
if preference and preference != "auto":
|
||||
return preference
|
||||
|
||||
# Cache 60 s pour ne pas marteler /api/tags.
|
||||
now = time.time()
|
||||
if now - _embed_model_cache["checked_at"] < 60:
|
||||
return _embed_model_cache["value"]
|
||||
|
||||
value = None
|
||||
try:
|
||||
for m in await ollama.list_models():
|
||||
name = (m.get("name") or "").lower()
|
||||
if any(h in name for h in _EMBED_HINTS):
|
||||
value = m["name"]
|
||||
break
|
||||
except (httpx.HTTPError, OSError):
|
||||
value = None
|
||||
|
||||
_embed_model_cache.update(value=value, checked_at=now)
|
||||
return value
|
||||
|
||||
|
||||
def _cosine(a: list[float], b: list[float]) -> float:
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(x * x for x in b))
|
||||
return dot / (na * nb) if na and nb else 0.0
|
||||
|
||||
|
||||
async def index_exchange(
|
||||
sid: str, user_text: str, assistant_text: str, *, embed_model: str | None
|
||||
) -> None:
|
||||
"""Indexe un échange terminé (tâche d'arrière-plan, best-effort)."""
|
||||
model = await resolve_embed_model(embed_model)
|
||||
if not model:
|
||||
return
|
||||
content = f"Q: {user_text[:500]}\nR: {assistant_text[:800]}"
|
||||
try:
|
||||
vectors = await ollama.embed(model, [content])
|
||||
if not vectors:
|
||||
return
|
||||
with db._LOCK, db._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO memories (id, session_id, content, embedding, created_at)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(uuid.uuid4().hex, sid, content,
|
||||
json.dumps(vectors[0]), time.time()),
|
||||
)
|
||||
# Élagage des souvenirs les plus anciens.
|
||||
conn.execute(
|
||||
"DELETE FROM memories WHERE id IN ("
|
||||
" SELECT id FROM memories ORDER BY created_at DESC"
|
||||
f" LIMIT -1 OFFSET {_MAX_MEMORIES})"
|
||||
)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Indexation RAG impossible : %s", exc)
|
||||
|
||||
|
||||
async def recall(
|
||||
sid: str, query: str, *, embed_model: str | None
|
||||
) -> list[str]:
|
||||
"""Souvenirs pertinents issus des AUTRES sessions (top-k, score minimal)."""
|
||||
model = await resolve_embed_model(embed_model)
|
||||
if not model:
|
||||
return []
|
||||
try:
|
||||
vectors = await ollama.embed(model, [query[:800]])
|
||||
if not vectors:
|
||||
return []
|
||||
qvec = vectors[0]
|
||||
|
||||
with db._LOCK, db._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT content, embedding FROM memories WHERE session_id != ?",
|
||||
(sid,),
|
||||
).fetchall()
|
||||
|
||||
scored = []
|
||||
for row in rows:
|
||||
try:
|
||||
score = _cosine(qvec, json.loads(row["embedding"]))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if score >= _MIN_SCORE:
|
||||
scored.append((score, row["content"]))
|
||||
scored.sort(reverse=True)
|
||||
return [c for _, c in scored[:_TOP_K]]
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
logger.warning("Rappel RAG impossible : %s", exc)
|
||||
return []
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Routes du benchmark de modèles."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import bench
|
||||
|
||||
router = APIRouter(prefix="/api/bench", tags=["bench"])
|
||||
|
||||
|
||||
class BenchRequest(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def scores() -> dict:
|
||||
return {"scores": bench.get_scores()}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def run(req: BenchRequest) -> StreamingResponse:
|
||||
async def event_stream():
|
||||
async for ev in bench.run_bench(req.model):
|
||||
etype = ev.pop("type")
|
||||
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
+136
-16
@@ -18,7 +18,8 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import agent_config, coder, db, memory, router as msg_router
|
||||
from .. import agent_config, coder, db, enhance, memory, rag, router as msg_router
|
||||
from ..tools import check_html, _safe_path
|
||||
from ..agent import run_agent
|
||||
from ..config import settings
|
||||
|
||||
@@ -36,33 +37,92 @@ 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}})
|
||||
|
||||
async def _run_aider_keepalive(instruction: str, model: str):
|
||||
"""Lance Aider dans un thread en gardant le flux SSE vivant."""
|
||||
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 None # signal keepalive
|
||||
yield await task
|
||||
|
||||
|
||||
async def _code_stream(
|
||||
req: ChatRequest, model: str, *, extra: str = "", plan: list[str] | None = None
|
||||
):
|
||||
"""Chemin « moteur code » : Aider + vérification HTML avec auto-correction."""
|
||||
instruction = req.content + (extra or "")
|
||||
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": req.content}})
|
||||
|
||||
result = None
|
||||
async for item in _run_aider_keepalive(instruction, model):
|
||||
if item is None:
|
||||
yield ": keepalive\n\n"
|
||||
result = await task
|
||||
else:
|
||||
result = item
|
||||
|
||||
status = "ok" if result.get("ok") else "error"
|
||||
record = {
|
||||
"name": "code_task",
|
||||
"args": {"instruction": instruction},
|
||||
"args": {"instruction": req.content},
|
||||
"summary": result.get("summary", "terminé"),
|
||||
"status": status,
|
||||
}
|
||||
tools_meta = [record]
|
||||
yield _sse("tool_result", record)
|
||||
|
||||
all_files = list(result.get("files") or [])
|
||||
|
||||
# Vérification des pages HTML produites + une passe d'auto-correction.
|
||||
html_issues: list[str] = []
|
||||
for f in all_files:
|
||||
if f.lower().endswith((".html", ".htm")):
|
||||
try:
|
||||
issues = check_html(_safe_path(f))
|
||||
except Exception:
|
||||
issues = []
|
||||
if issues:
|
||||
html_issues.append(f"{f} : " + " ; ".join(issues))
|
||||
|
||||
if html_issues and result.get("ok"):
|
||||
yield _sse("tool_call", {"name": "html_check", "args": {"path": "vérification"}})
|
||||
yield _sse("tool_result", {
|
||||
"name": "html_check", "args": {"path": "vérification"},
|
||||
"summary": " | ".join(html_issues)[:200], "status": "error",
|
||||
})
|
||||
tools_meta.append({
|
||||
"name": "html_check", "args": {},
|
||||
"summary": " | ".join(html_issues)[:200], "status": "error",
|
||||
})
|
||||
fix_instruction = (
|
||||
"Corrige ces problèmes détectés dans les fichiers HTML, sans rien "
|
||||
"casser d'autre :\n" + "\n".join(html_issues)
|
||||
)
|
||||
fix = None
|
||||
async for item in _run_aider_keepalive(fix_instruction, model):
|
||||
if item is None:
|
||||
yield ": keepalive\n\n"
|
||||
else:
|
||||
fix = item
|
||||
fix_rec = {
|
||||
"name": "code_task",
|
||||
"args": {"instruction": "auto-correction HTML"},
|
||||
"summary": fix.get("summary", "terminé"),
|
||||
"status": "ok" if fix.get("ok") else "error",
|
||||
}
|
||||
tools_meta.append(fix_rec)
|
||||
yield _sse("tool_call", {"name": "code_task", "args": fix_rec["args"]})
|
||||
yield _sse("tool_result", fix_rec)
|
||||
for f in fix.get("files") or []:
|
||||
if f not in all_files:
|
||||
all_files.append(f)
|
||||
if fix.get("text"):
|
||||
result["text"] = (result.get("text") or "") + "\n\n" + fix["text"]
|
||||
|
||||
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
|
||||
for f in result.get("files") or []:
|
||||
for f in all_files:
|
||||
file_rec = {
|
||||
"name": "write_file",
|
||||
"args": {"path": f},
|
||||
@@ -79,10 +139,10 @@ async def _code_stream(req: ChatRequest, model: str):
|
||||
if text:
|
||||
yield _sse("token", {"content": text})
|
||||
|
||||
db.add_message(
|
||||
req.session_id, "assistant", text, model,
|
||||
meta={"tools": tools_meta, "engine": "code"},
|
||||
)
|
||||
meta: dict = {"tools": tools_meta, "engine": "code"}
|
||||
if plan:
|
||||
meta["plan"] = plan
|
||||
db.add_message(req.session_id, "assistant", text, model, meta=meta)
|
||||
yield _sse("done", {"content": text, "tools": tools_meta})
|
||||
|
||||
|
||||
@@ -113,22 +173,61 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
# Mémoire compressée : système + résumé des anciens tours + messages récents.
|
||||
convo = memory.build_convo(req.session_id, cfg["system_prompt"])
|
||||
|
||||
# Mémoire long-terme (RAG) : souvenirs pertinents des autres sessions.
|
||||
if cfg.get("rag_enabled", True):
|
||||
memories = await rag.recall(
|
||||
req.session_id, req.content, embed_model=cfg.get("embed_model")
|
||||
)
|
||||
if memories:
|
||||
convo.insert(1, {
|
||||
"role": "system",
|
||||
"content": "Souvenirs pertinents d'anciennes sessions :\n"
|
||||
+ "\n---\n".join(memories),
|
||||
})
|
||||
|
||||
# Moteur code : choisit le meilleur modèle code installé (config "auto").
|
||||
code_model = (
|
||||
await coder.pick_code_model(model, cfg.get("code_model"))
|
||||
if use_code else model
|
||||
)
|
||||
|
||||
# Plan-puis-exécute : les demandes complexes sont décomposées d'abord.
|
||||
plan: list[str] = []
|
||||
if cfg.get("plan_mode", True) and (use_code or enhance.needs_plan(req.content)):
|
||||
plan = await enhance.make_plan(model, req.content)
|
||||
|
||||
async def event_stream():
|
||||
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
|
||||
|
||||
if plan:
|
||||
yield _sse("plan", {"steps": plan})
|
||||
|
||||
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
|
||||
if use_code:
|
||||
async for chunk in _code_stream(req, code_model):
|
||||
instruction_plan = (
|
||||
"\n\nPlan à suivre :\n"
|
||||
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
|
||||
if plan else ""
|
||||
)
|
||||
async for chunk in _code_stream(req, code_model, extra=instruction_plan, plan=plan):
|
||||
yield chunk
|
||||
asyncio.create_task(memory.maybe_summarize(req.session_id, model))
|
||||
if cfg.get("rag_enabled", True):
|
||||
last = db.list_messages(req.session_id)
|
||||
answer = last[-1]["content"] if last else ""
|
||||
asyncio.create_task(rag.index_exchange(
|
||||
req.session_id, req.content, answer,
|
||||
embed_model=cfg.get("embed_model"),
|
||||
))
|
||||
return
|
||||
|
||||
if plan:
|
||||
convo.append({
|
||||
"role": "system",
|
||||
"content": "Plan à suivre pour cette demande :\n"
|
||||
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan)),
|
||||
})
|
||||
|
||||
final_content = ""
|
||||
tools_meta: list[dict] = []
|
||||
stats_meta: dict | None = None
|
||||
@@ -198,6 +297,20 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await producer
|
||||
|
||||
# Auto-critique : relecture éclair puis révision (option « Qualité + »).
|
||||
if (
|
||||
cfg.get("self_review", False)
|
||||
and final_content
|
||||
and not error_message
|
||||
and not tools_meta
|
||||
):
|
||||
yield _sse("status", {"message": "Relecture de la réponse…"})
|
||||
revised = await enhance.self_review(model, req.content, final_content)
|
||||
if revised:
|
||||
final_content = revised
|
||||
yield _sse("revision", {"content": revised})
|
||||
yield _sse("notice", {"message": "Réponse révisée après auto-critique ✓"})
|
||||
|
||||
if final_content or tools_meta:
|
||||
meta: dict = {}
|
||||
if tools_meta:
|
||||
@@ -206,6 +319,8 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
meta["stats"] = stats_meta
|
||||
if thinking_meta:
|
||||
meta["thinking"] = thinking_meta
|
||||
if plan:
|
||||
meta["plan"] = plan
|
||||
db.add_message(
|
||||
req.session_id,
|
||||
"assistant",
|
||||
@@ -222,8 +337,13 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
"error": error_message or None,
|
||||
},
|
||||
)
|
||||
# Compression de l'historique en arrière-plan (sans bloquer la réponse).
|
||||
# Tâches d'arrière-plan : compression de l'historique + mémoire RAG.
|
||||
asyncio.create_task(memory.maybe_summarize(req.session_id, model))
|
||||
if cfg.get("rag_enabled", True) and final_content:
|
||||
asyncio.create_task(rag.index_exchange(
|
||||
req.session_id, req.content, final_content,
|
||||
embed_model=cfg.get("embed_model"),
|
||||
))
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
|
||||
@@ -22,6 +22,10 @@ class ConfigPatch(BaseModel):
|
||||
confirm_shell: bool | None = None
|
||||
think: bool | None = None
|
||||
code_model: str | None = None
|
||||
plan_mode: bool | None = None
|
||||
self_review: bool | None = None
|
||||
rag_enabled: bool | None = None
|
||||
embed_model: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
+37
-1
@@ -50,8 +50,40 @@ def read_file(path: str) -> dict:
|
||||
return {"ok": True, "content": content, "summary": summary}
|
||||
|
||||
|
||||
def check_html(target: str) -> list[str]:
|
||||
"""Contrôles rapides d'une page HTML : références locales et balises.
|
||||
|
||||
Renvoie une liste de problèmes (vide = OK).
|
||||
"""
|
||||
issues: list[str] = []
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError:
|
||||
return issues
|
||||
|
||||
base_dir = os.path.dirname(target)
|
||||
|
||||
# Références locales cassées (href/src vers un fichier absent).
|
||||
for _, ref in re.findall(r"""(href|src)=["']([^"'#]+)["']""", content, re.I):
|
||||
if re.match(r"^(https?:|data:|mailto:|tel:|//|javascript:)", ref, re.I):
|
||||
continue
|
||||
ref_path = os.path.normpath(os.path.join(base_dir, ref.split("?")[0]))
|
||||
if not os.path.exists(ref_path):
|
||||
issues.append(f"référence cassée : {ref}")
|
||||
|
||||
# Équilibre des balises structurantes.
|
||||
for tag in ("html", "head", "body", "div", "section", "script", "style"):
|
||||
opened = len(re.findall(rf"<{tag}[\s>]", content, re.I))
|
||||
closed = len(re.findall(rf"</{tag}>", content, re.I))
|
||||
if opened != closed:
|
||||
issues.append(f"balise <{tag}> : {opened} ouverte(s) / {closed} fermée(s)")
|
||||
|
||||
return issues[:6]
|
||||
|
||||
|
||||
def _verify_written(target: str) -> str | None:
|
||||
"""Vérification syntaxique immédiate après écriture (py/json).
|
||||
"""Vérification immédiate après écriture (py/json/html).
|
||||
|
||||
Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans
|
||||
le même tour, au lieu de livrer un fichier cassé.
|
||||
@@ -65,6 +97,10 @@ def _verify_written(target: str) -> str | None:
|
||||
_json.loads(content)
|
||||
elif ext == ".py":
|
||||
compile(content, target, "exec")
|
||||
elif ext in (".html", ".htm"):
|
||||
problems = check_html(target)
|
||||
if problems:
|
||||
return " ; ".join(problems)
|
||||
except SyntaxError as exc:
|
||||
return f"SyntaxError ligne {exc.lineno}: {exc.msg}"
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -89,7 +89,13 @@ export interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
model?: string;
|
||||
meta?: { tools?: ToolCall[]; stats?: MessageStats; thinking?: string } | null;
|
||||
meta?: {
|
||||
tools?: ToolCall[];
|
||||
stats?: MessageStats;
|
||||
thinking?: string;
|
||||
plan?: string[];
|
||||
engine?: string;
|
||||
} | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
@@ -105,6 +111,75 @@ export interface AgentConfig {
|
||||
tools: Record<string, boolean>;
|
||||
confirm_shell: boolean;
|
||||
think: boolean;
|
||||
code_model: string;
|
||||
plan_mode: boolean;
|
||||
self_review: boolean;
|
||||
rag_enabled: boolean;
|
||||
embed_model: string;
|
||||
}
|
||||
|
||||
// ── Benchmark de modèles ─────────────────────────────────────────────────
|
||||
export interface BenchDetail {
|
||||
task: string;
|
||||
score: number;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface BenchResult {
|
||||
score: number;
|
||||
details: BenchDetail[];
|
||||
at: number;
|
||||
}
|
||||
|
||||
export async function getBenchScores(): Promise<Record<string, BenchResult>> {
|
||||
const res = await fetch("/api/bench");
|
||||
return (await res.json()).scores;
|
||||
}
|
||||
|
||||
/** Lance le benchmark d'un modèle en streamant la progression. */
|
||||
export async function runBench(
|
||||
model: string,
|
||||
onProgress: (task: string, score: number | null, detail?: string) => void
|
||||
): Promise<BenchResult | null> {
|
||||
const res = await fetch("/api/bench", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.body) return null;
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let final: BenchResult | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split("\n\n");
|
||||
buffer = events.pop() ?? "";
|
||||
for (const block of events) {
|
||||
let event = "";
|
||||
let data = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event: ")) event = line.slice(7).trim();
|
||||
else if (line.startsWith("data: ")) data += line.slice(6);
|
||||
}
|
||||
if (!data) continue;
|
||||
try {
|
||||
const payload = JSON.parse(data);
|
||||
if (event === "task_start") onProgress(payload.task, null);
|
||||
else if (event === "task_done")
|
||||
onProgress(payload.task, payload.score, payload.detail);
|
||||
else if (event === "done")
|
||||
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
|
||||
} catch {
|
||||
/* bloc partiel */
|
||||
}
|
||||
}
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
export async function runShell(
|
||||
@@ -204,6 +279,8 @@ export async function streamChat(
|
||||
onToolConfirm: (command: string) => void;
|
||||
onStatus: (msg: string) => void;
|
||||
onNotice: (msg: string) => void;
|
||||
onPlan?: (steps: string[]) => void;
|
||||
onRevision?: (content: string) => void;
|
||||
onDone: (full: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
onAbort?: () => void;
|
||||
@@ -265,6 +342,8 @@ export async function streamChat(
|
||||
try {
|
||||
const payload = JSON.parse(dataLines.join("\n"));
|
||||
if (event === "token") handlers.onToken(payload.content);
|
||||
else if (event === "plan") handlers.onPlan?.(payload.steps);
|
||||
else if (event === "revision") handlers.onRevision?.(payload.content);
|
||||
else if (event === "thinking") handlers.onThinking(payload.content);
|
||||
else if (event === "status") handlers.onStatus(payload.message);
|
||||
else if (event === "notice") handlers.onNotice(payload.message);
|
||||
|
||||
@@ -17,6 +17,7 @@ export function ChatPanel() {
|
||||
streamStatus,
|
||||
streamNotice,
|
||||
streamTools,
|
||||
streamPlan,
|
||||
sendMessage,
|
||||
currentSessionId,
|
||||
config,
|
||||
@@ -37,7 +38,7 @@ export function ChatPanel() {
|
||||
// Auto-scroll vers le bas à chaque token / message.
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||
}, [messages, streamContent, streamThinking, streamTools]);
|
||||
}, [messages, streamContent, streamThinking, streamTools, streamPlan]);
|
||||
|
||||
const submit = () => {
|
||||
if (!draft.trim() || streaming) return;
|
||||
@@ -101,7 +102,7 @@ export function ChatPanel() {
|
||||
role: "assistant",
|
||||
content: streamContent,
|
||||
model: selectedModel,
|
||||
meta: { tools: streamTools },
|
||||
meta: { tools: streamTools, plan: streamPlan },
|
||||
created_at: Date.now() / 1000,
|
||||
}}
|
||||
pending
|
||||
@@ -173,6 +174,33 @@ export function ChatPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Plan d'exécution affiché avant le travail de l'agent. */
|
||||
function PlanCard({ steps }: { steps: string[] }) {
|
||||
return (
|
||||
<div
|
||||
className="mb-[11px] overflow-hidden border-[3px] border-line bg-card shadow-hard-sm"
|
||||
style={{ borderRadius: 7 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b-2 border-line-soft px-3 py-2">
|
||||
<span className="font-pixel text-[9px] text-accent">PLAN</span>
|
||||
<span className="text-[12px] text-muted-2">
|
||||
{steps.length} étape{steps.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ol className="m-0 list-none px-3 py-2">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i} className="flex gap-2 py-[3px] text-[13px] text-ink-2">
|
||||
<span className="flex h-[18px] w-[18px] flex-none items-center justify-center border-2 border-line bg-base text-[11px] text-ink">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="min-w-0">{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bubble({
|
||||
msg,
|
||||
pending,
|
||||
@@ -227,6 +255,9 @@ function Bubble({
|
||||
text={thinking ?? msg.meta?.thinking ?? ""}
|
||||
live={!!pending}
|
||||
/>
|
||||
{(msg.meta?.plan?.length ?? 0) > 0 && (
|
||||
<PlanCard steps={msg.meta!.plan!} />
|
||||
)}
|
||||
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
|
||||
<ToolCard key={i} call={t} />
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { deleteModel, pullModel } from "../api/client";
|
||||
import type { AgentConfig } from "../api/client";
|
||||
import { deleteModel, getBenchScores, pullModel, runBench } from "../api/client";
|
||||
import type { AgentConfig, BenchResult } from "../api/client";
|
||||
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
||||
|
||||
const TOOL_DESC: Record<string, string> = {
|
||||
@@ -376,6 +376,38 @@ export function SettingsView() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="mb-3.5 font-pixel text-[11px] text-ink">
|
||||
INTELLIGENCE
|
||||
</div>
|
||||
{[
|
||||
["plan_mode", "Plan-puis-exécute",
|
||||
"Décompose les demandes complexes en étapes"] as const,
|
||||
["self_review", "Auto-critique (Qualité +)",
|
||||
"Relit et révise la réponse avant de la donner"] as const,
|
||||
["rag_enabled", "Mémoire long-terme (RAG)",
|
||||
"Se souvient des anciennes sessions (modèle d'embedding requis)"] as const,
|
||||
].map(([key, label, desc], i) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex items-center gap-3 py-2 ${
|
||||
i > 0 ? "border-t-2 border-line-soft" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="block text-[14px] text-ink">{label}</span>
|
||||
<span className="block text-[12px] text-muted-2">{desc}</span>
|
||||
</span>
|
||||
<Toggle
|
||||
on={Boolean(draft[key])}
|
||||
onClick={() => set(key, !draft[key])}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<BenchCard />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -403,6 +435,108 @@ export function SettingsView() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Benchmark : évalue le modèle sélectionné sur 5 mini-épreuves. */
|
||||
function BenchCard() {
|
||||
const selectedModel = useStore((s) => s.selectedModel);
|
||||
const [scores, setScores] = useState<Record<string, BenchResult>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState<
|
||||
{ task: string; score: number | null; detail?: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getBenchScores().then(setScores).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const launch = async () => {
|
||||
if (!selectedModel || running) return;
|
||||
setRunning(true);
|
||||
setProgress([]);
|
||||
try {
|
||||
const result = await runBench(selectedModel, (task, score, detail) => {
|
||||
setProgress((p) => {
|
||||
const rest = p.filter((x) => x.task !== task);
|
||||
return [...rest, { task, score, detail }];
|
||||
});
|
||||
});
|
||||
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const current = scores[selectedModel];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="font-pixel text-[11px] text-ink">BENCHMARK</div>
|
||||
<button
|
||||
onClick={launch}
|
||||
disabled={running || !selectedModel}
|
||||
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-accent px-3 text-[13px] text-white shadow-accent-soft disabled:opacity-40"
|
||||
style={{ borderRadius: 7 }}
|
||||
>
|
||||
{running ? "ÉVALUATION…" : "TESTER CE MODÈLE"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-[13px] text-muted-2">
|
||||
5 mini-épreuves (outils, code, consignes, JSON, format) — score /100
|
||||
pour <b className="text-ink">{selectedModel || "—"}</b>.
|
||||
</div>
|
||||
|
||||
{running && (
|
||||
<div className="border-2 border-line bg-base px-3 py-2">
|
||||
{progress.map((p) => (
|
||||
<div key={p.task} className="flex items-center gap-2 py-[2px] text-[13px]">
|
||||
<span className="flex-1 text-ink-2">{p.task}</span>
|
||||
{p.score === null ? (
|
||||
<span className="text-muted-3">…</span>
|
||||
) : (
|
||||
<span className={p.score >= 14 ? "text-ok" : p.score >= 8 ? "text-ink" : "text-warn"}>
|
||||
{p.score}/20
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!running && current && (
|
||||
<div className="border-2 border-line bg-base px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-[13px] text-ink">Score</span>
|
||||
<span
|
||||
className={`font-pixel text-[13px] ${
|
||||
current.score >= 70 ? "text-ok" : current.score >= 45 ? "text-ink" : "text-warn"
|
||||
}`}
|
||||
>
|
||||
{current.score}/100
|
||||
</span>
|
||||
</div>
|
||||
{current.details.map((d) => (
|
||||
<div key={d.task} className="flex items-center gap-2 py-[2px] text-[12px]">
|
||||
<span className="flex-1 text-muted-2">{d.task}</span>
|
||||
<span className="text-muted" title={d.detail}>{d.score}/20</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(scores).length > 1 && (
|
||||
<div className="mt-2 text-[12px] text-muted-2">
|
||||
Autres :{" "}
|
||||
{Object.entries(scores)
|
||||
.filter(([m]) => m !== selectedModel)
|
||||
.map(([m, r]) => `${m} (${r.score})`)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
children,
|
||||
className = "",
|
||||
|
||||
@@ -40,6 +40,7 @@ interface LokiState {
|
||||
streamStatus: string;
|
||||
streamNotice: string | null;
|
||||
streamTools: ToolCall[]; // appels d'outils de la réponse en cours
|
||||
streamPlan: string[]; // plan de la réponse en cours
|
||||
|
||||
fileTree: FileNode[];
|
||||
previewPath: string | null;
|
||||
@@ -88,6 +89,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
streamStatus: "",
|
||||
streamNotice: null,
|
||||
streamTools: [],
|
||||
streamPlan: [],
|
||||
fileTree: [],
|
||||
previewPath: null,
|
||||
previewContent: "",
|
||||
@@ -234,6 +236,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
streamStatus: "",
|
||||
streamNotice: null,
|
||||
streamTools: [],
|
||||
streamPlan: [],
|
||||
pendingShell: null,
|
||||
});
|
||||
void get().refreshSessions();
|
||||
@@ -305,6 +308,8 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
onToken: (t) => set({ streamContent: get().streamContent + t }),
|
||||
onThinking: (t) => set({ streamThinking: get().streamThinking + t }),
|
||||
onStatus: (message) => set({ streamStatus: message }),
|
||||
onPlan: (steps) => set({ streamPlan: steps }),
|
||||
onRevision: (content) => set({ streamContent: content }),
|
||||
onNotice: (message) => set({ streamNotice: message }),
|
||||
onToolCall: (call) =>
|
||||
set({ streamTools: [...get().streamTools, call] }),
|
||||
|
||||
Reference in new issue
Block a user