mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Mémoire en notes Markdown + presets de configuration (repris d'AJEAN)
Les deux dernières idées d'AJEAN (github.com/nathaninline/jean). 1. Mémoire en notes Markdown ($DATA_DIR/MEMORY), écrites par l'agent via deux outils (memory_search / memory_save). Trois modes : off, ondemand (défaut) et always. Face au RAG vectoriel : aucun modèle d'embedding requis, notes lisibles et éditables à la main, et rien de mémorisé à l'insu — la recherche par mots-clés ne rappelle que du pertinent, là où le RAG ressortait une demande sans rapport. Le RAG disparaît de l'UI : un seul concept de mémoire. En mode Plan, seule la consultation est proposée, pas l'écriture. 2. Presets : jeux de réglages nommés, enregistrés et rappelés en un clic (sélecteur + « + Preset » dans l'en-tête des Réglages). Au passage, un vrai bug : ConfigPatch ne déclarait ni keep_alive, ni skills_enabled, ni ponytail — Pydantic ignore les champs non déclarés, donc ces trois réglages ne se sauvegardaient JAMAIS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
7 files changed
+463
-5
No files matched your search
@@ -5,6 +5,8 @@ fusion avec ce qui est stocké, pour rester robuste aux montées de version.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
|
||||||
CONFIG_KEY = "agent"
|
CONFIG_KEY = "agent"
|
||||||
@@ -71,6 +73,7 @@ PROFILE_FIELDS = {
|
|||||||
"self_review",
|
"self_review",
|
||||||
"rag_enabled",
|
"rag_enabled",
|
||||||
"embed_model",
|
"embed_model",
|
||||||
|
"memory_mode",
|
||||||
"skills_enabled",
|
"skills_enabled",
|
||||||
"ponytail",
|
"ponytail",
|
||||||
"keep_alive",
|
"keep_alive",
|
||||||
@@ -124,6 +127,11 @@ DEFAULT_CONFIG: dict = {
|
|||||||
# d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages.
|
# d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages.
|
||||||
"rag_enabled": False,
|
"rag_enabled": False,
|
||||||
"embed_model": "auto",
|
"embed_model": "auto",
|
||||||
|
# Mémoire en notes Markdown écrites par l'agent (voir memory_notes.py).
|
||||||
|
# "ondemand" : les outils existent, mais rien n'est injecté sans que le
|
||||||
|
# modèle le demande — aucun risque de voir ressurgir une demande sans
|
||||||
|
# rapport, contrairement à l'ancien RAG vectoriel.
|
||||||
|
"memory_mode": "ondemand",
|
||||||
# Skills : méthodes expertes injectées automatiquement selon la tâche.
|
# Skills : méthodes expertes injectées automatiquement selon la tâche.
|
||||||
"skills_enabled": True,
|
"skills_enabled": True,
|
||||||
# Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour
|
# Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour
|
||||||
@@ -247,6 +255,42 @@ def ollama_options(cfg: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Presets : jeux de réglages nommés ────────────────────────────────────
|
||||||
|
PRESETS_KEY = "config_presets"
|
||||||
|
_PRESET_NAME = re.compile(r"^[\w \-]{1,40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets() -> list[str]:
|
||||||
|
return sorted((db.get_config_value(PRESETS_KEY) or {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def save_preset(name: str, model: str | None = None) -> list[str]:
|
||||||
|
"""Fige la configuration courante sous ce nom (écrase si déjà pris)."""
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not _PRESET_NAME.match(name):
|
||||||
|
raise ValueError("nom de preset invalide (lettres, chiffres, espaces, -)")
|
||||||
|
presets = db.get_config_value(PRESETS_KEY) or {}
|
||||||
|
cfg = get_config(model)
|
||||||
|
presets[name] = {field: cfg[field] for field in PROFILE_FIELDS if field in cfg}
|
||||||
|
db.set_config_value(PRESETS_KEY, presets)
|
||||||
|
return sorted(presets)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_preset(name: str, model: str | None = None) -> dict | None:
|
||||||
|
"""Applique un preset ; None s'il n'existe pas."""
|
||||||
|
preset = (db.get_config_value(PRESETS_KEY) or {}).get((name or "").strip())
|
||||||
|
if preset is None:
|
||||||
|
return None
|
||||||
|
return save_config(dict(preset), model)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_preset(name: str) -> list[str]:
|
||||||
|
presets = db.get_config_value(PRESETS_KEY) or {}
|
||||||
|
presets.pop((name or "").strip(), None)
|
||||||
|
db.set_config_value(PRESETS_KEY, presets)
|
||||||
|
return sorted(presets)
|
||||||
|
|
||||||
|
|
||||||
def enabled_tool_names(cfg: dict) -> list[str]:
|
def enabled_tool_names(cfg: dict) -> list[str]:
|
||||||
"""Outils réellement proposables au modèle.
|
"""Outils réellement proposables au modèle.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Mémoire en notes Markdown : l'agent écrit lui-même ce qu'il veut retenir.
|
||||||
|
|
||||||
|
Repris d'AJEAN (github.com/nathaninline/jean) : des notes lisibles rangées
|
||||||
|
dans ``$DATA_DIR/MEMORY/``, que le modèle consulte et enregistre EXPLICITEMENT.
|
||||||
|
|
||||||
|
Face au RAG vectoriel, trois avantages décisifs :
|
||||||
|
- aucun modèle d'embedding requis (marche dès l'installation) ;
|
||||||
|
- le contenu est inspectable et modifiable à la main ;
|
||||||
|
- rien n'est mémorisé « à l'insu » : pas de vieille demande sans rapport qui
|
||||||
|
ressurgit au milieu d'une nouvelle discussion.
|
||||||
|
|
||||||
|
Trois modes (config ``memory_mode``) :
|
||||||
|
off — rien du tout ;
|
||||||
|
ondemand — les outils memory_search / memory_save sont proposés au modèle ;
|
||||||
|
always — idem, plus l'injection automatique des notes pertinentes.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
MODES = ("off", "ondemand", "always")
|
||||||
|
|
||||||
|
# Bornes : une note reste une note, pas une archive.
|
||||||
|
_MAX_NOTE_CHARS = 4000
|
||||||
|
_MAX_NOTES = 200
|
||||||
|
_EXCERPT_CHARS = 400
|
||||||
|
|
||||||
|
|
||||||
|
def _dir() -> str:
|
||||||
|
path = os.path.join(os.path.abspath(settings.data_dir), "MEMORY")
|
||||||
|
os.makedirs(path, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(title: str) -> str:
|
||||||
|
"""Nom de fichier sûr dérivé du titre (confiné au dossier MEMORY)."""
|
||||||
|
text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode()
|
||||||
|
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
|
||||||
|
return (text or "note")[:60]
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(text: str) -> set[str]:
|
||||||
|
"""Mots significatifs, sans accents ni casse (>= 3 lettres)."""
|
||||||
|
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
|
||||||
|
return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower())}
|
||||||
|
|
||||||
|
|
||||||
|
def save_note(title: str, content: str) -> dict:
|
||||||
|
"""Crée ou remplace une note. Renvoie {ok, summary}."""
|
||||||
|
title = (title or "").strip()
|
||||||
|
content = (content or "").strip()
|
||||||
|
if not title:
|
||||||
|
return {"ok": False, "summary": "titre vide"}
|
||||||
|
if not content:
|
||||||
|
return {"ok": False, "summary": "contenu vide"}
|
||||||
|
|
||||||
|
content = content[:_MAX_NOTE_CHARS]
|
||||||
|
path = os.path.join(_dir(), f"{_slug(title)}.md")
|
||||||
|
existed = os.path.isfile(path)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(f"# {title}\n\n{content}\n")
|
||||||
|
|
||||||
|
_prune()
|
||||||
|
verb = "mise à jour" if existed else "enregistrée"
|
||||||
|
return {"ok": True, "summary": f"note {verb} : {title}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _prune() -> None:
|
||||||
|
"""Garde les notes les plus récentes (borne dure, jamais de purge totale)."""
|
||||||
|
files = [os.path.join(_dir(), n) for n in os.listdir(_dir()) if n.endswith(".md")]
|
||||||
|
if len(files) <= _MAX_NOTES:
|
||||||
|
return
|
||||||
|
files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||||
|
for path in files[_MAX_NOTES:]:
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _read_all() -> list[tuple[str, str]]:
|
||||||
|
"""[(titre, corps)] de toutes les notes."""
|
||||||
|
out = []
|
||||||
|
try:
|
||||||
|
names = sorted(os.listdir(_dir()))
|
||||||
|
except OSError:
|
||||||
|
return out
|
||||||
|
for name in names:
|
||||||
|
if not name.endswith(".md"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(os.path.join(_dir(), name), encoding="utf-8", errors="replace") as f:
|
||||||
|
raw = f.read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
first, _, body = raw.partition("\n")
|
||||||
|
title = first.lstrip("# ").strip() or name[:-3]
|
||||||
|
out.append((title, body.strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def search_notes(query: str, limit: int = 3) -> list[dict]:
|
||||||
|
"""Notes les plus proches de la requête (recouvrement de mots-clés)."""
|
||||||
|
wanted = _tokens(query)
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
scored: list[tuple[int, str, str]] = []
|
||||||
|
for title, body in _read_all():
|
||||||
|
score = len(wanted & _tokens(f"{title} {body}"))
|
||||||
|
if score:
|
||||||
|
scored.append((score, title, body))
|
||||||
|
scored.sort(key=lambda item: -item[0])
|
||||||
|
return [
|
||||||
|
{"title": title, "content": body[:_EXCERPT_CHARS]}
|
||||||
|
for _, title, body in scored[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_notes() -> list[dict]:
|
||||||
|
"""Inventaire des notes (pour l'UI / l'inspection)."""
|
||||||
|
notes = []
|
||||||
|
for title, body in _read_all():
|
||||||
|
path = os.path.join(_dir(), f"{_slug(title)}.md")
|
||||||
|
notes.append({
|
||||||
|
"title": title,
|
||||||
|
"chars": len(body),
|
||||||
|
"updated_at": os.path.getmtime(path) if os.path.isfile(path) else time.time(),
|
||||||
|
})
|
||||||
|
return sorted(notes, key=lambda n: -n["updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def delete_note(title: str) -> bool:
|
||||||
|
path = os.path.join(_dir(), f"{_slug(title)}.md")
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
os.remove(path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def recall_block(message: str) -> str | None:
|
||||||
|
"""Bloc de contexte à injecter en mode ``always`` (None si rien de net)."""
|
||||||
|
hits = search_notes(message, limit=2)
|
||||||
|
if not hits:
|
||||||
|
return None
|
||||||
|
body = "\n\n".join(f"## {h['title']}\n{h['content']}" for h in hits)
|
||||||
|
return (
|
||||||
|
"Notes que tu avais enregistrées et qui semblent liées à cette demande "
|
||||||
|
"(ignore-les si elles ne s'appliquent pas) :\n" + body
|
||||||
|
)
|
||||||
@@ -20,7 +20,9 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import agent_config, coder, db, enhance, memory, rag, skills, tools
|
from .. import (
|
||||||
|
agent_config, coder, db, enhance, memory, memory_notes, rag, skills, tools,
|
||||||
|
)
|
||||||
from .. import router as msg_router
|
from .. import router as msg_router
|
||||||
from ..tools import check_html, _safe_path
|
from ..tools import check_html, _safe_path
|
||||||
from ..agent import run_agent
|
from ..agent import run_agent
|
||||||
@@ -437,6 +439,24 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
if want_web:
|
if want_web:
|
||||||
convo.insert(1, {"role": "system", "content": skills.WEBAPP_GUIDANCE})
|
convo.insert(1, {"role": "system", "content": skills.WEBAPP_GUIDANCE})
|
||||||
|
|
||||||
|
# Mémoire en notes : en mode « always », on injecte les notes liées à la
|
||||||
|
# demande. Rien n'est deviné — ce sont des notes que l'agent a
|
||||||
|
# lui-même écrites, et le bloc dit explicitement de les ignorer si
|
||||||
|
# elles ne s'appliquent pas.
|
||||||
|
memory_mode = cfg.get("memory_mode", "ondemand")
|
||||||
|
# Mode off : les outils n'existent pas pour le modèle (principe AJEAN).
|
||||||
|
# En mode Plan (lecture seule), la consultation reste permise mais pas
|
||||||
|
# l'écriture d'une note.
|
||||||
|
memory_tools = (
|
||||||
|
[] if memory_mode == "off"
|
||||||
|
else ["memory_search"] if req.mode == "plan"
|
||||||
|
else ["memory_search", "memory_save"]
|
||||||
|
)
|
||||||
|
if memory_mode == "always":
|
||||||
|
block = memory_notes.recall_block(req.content)
|
||||||
|
if block:
|
||||||
|
convo.insert(1, {"role": "system", "content": block})
|
||||||
|
|
||||||
if plan:
|
if plan:
|
||||||
yield _sse("plan", {"steps": plan})
|
yield _sse("plan", {"steps": plan})
|
||||||
|
|
||||||
@@ -504,7 +524,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
model,
|
model,
|
||||||
convo,
|
convo,
|
||||||
options=agent_config.ollama_options(cfg),
|
options=agent_config.ollama_options(cfg),
|
||||||
enabled_tools=agent_config.enabled_tool_names(cfg),
|
enabled_tools=agent_config.enabled_tool_names(cfg) + memory_tools,
|
||||||
confirm_shell=cfg.get("confirm_shell", True),
|
confirm_shell=cfg.get("confirm_shell", True),
|
||||||
think=cfg.get("think", True),
|
think=cfg.get("think", True),
|
||||||
keep_alive=cfg.get("keep_alive", "30m"),
|
keep_alive=cfg.get("keep_alive", "30m"),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Routes de lecture et mise à jour de la configuration de l'agent."""
|
"""Routes de lecture et mise à jour de la configuration de l'agent."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import agent_config
|
from .. import agent_config
|
||||||
@@ -10,6 +10,13 @@ router = APIRouter(prefix="/api/config", tags=["config"])
|
|||||||
|
|
||||||
|
|
||||||
class ConfigPatch(BaseModel):
|
class ConfigPatch(BaseModel):
|
||||||
|
"""Champs modifiables. DOIT couvrir agent_config.PROFILE_FIELDS.
|
||||||
|
|
||||||
|
Pydantic ignore silencieusement un champ non déclaré : un oubli ici fait
|
||||||
|
« sauter » le réglage sans la moindre erreur (c'était le cas de
|
||||||
|
keep_alive, skills_enabled et ponytail).
|
||||||
|
"""
|
||||||
|
|
||||||
system_prompt: str | None = None
|
system_prompt: str | None = None
|
||||||
temperature: float | None = None
|
temperature: float | None = None
|
||||||
top_p: float | None = None
|
top_p: float | None = None
|
||||||
@@ -26,6 +33,10 @@ class ConfigPatch(BaseModel):
|
|||||||
self_review: bool | None = None
|
self_review: bool | None = None
|
||||||
rag_enabled: bool | None = None
|
rag_enabled: bool | None = None
|
||||||
embed_model: str | None = None
|
embed_model: str | None = None
|
||||||
|
memory_mode: str | None = None
|
||||||
|
skills_enabled: bool | None = None
|
||||||
|
ponytail: bool | None = None
|
||||||
|
keep_alive: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -40,3 +51,36 @@ async def get_config(model: str | None = None) -> dict:
|
|||||||
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
|
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
|
||||||
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
|
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
|
||||||
return {"config": cfg}
|
return {"config": cfg}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Presets : jeux de réglages nommés, commutables (idée reprise d'AJEAN) ──
|
||||||
|
class PresetBody(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/presets")
|
||||||
|
async def get_presets() -> dict:
|
||||||
|
return {"presets": agent_config.list_presets()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/presets")
|
||||||
|
async def post_preset(body: PresetBody, model: str | None = None) -> dict:
|
||||||
|
"""Enregistre la configuration courante sous un nom."""
|
||||||
|
try:
|
||||||
|
return {"presets": agent_config.save_preset(body.name, model)}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(400, str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/presets/apply")
|
||||||
|
async def apply_preset(body: PresetBody, model: str | None = None) -> dict:
|
||||||
|
"""Applique un preset à la configuration courante."""
|
||||||
|
cfg = agent_config.apply_preset(body.name, model)
|
||||||
|
if cfg is None:
|
||||||
|
raise HTTPException(404, "preset introuvable")
|
||||||
|
return {"config": cfg}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/presets")
|
||||||
|
async def remove_preset(name: str) -> dict:
|
||||||
|
return {"presets": agent_config.delete_preset(name)}
|
||||||
@@ -331,6 +331,30 @@ def edit_file(path: str, search: str, replace: str) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def memory_search(query: str) -> dict:
|
||||||
|
"""Cherche dans les notes que l'agent a lui-même enregistrées."""
|
||||||
|
from . import memory_notes
|
||||||
|
|
||||||
|
hits = memory_notes.search_notes(query, limit=3)
|
||||||
|
if not hits:
|
||||||
|
return {"ok": True, "notes": [], "summary": "aucune note correspondante"}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"notes": hits,
|
||||||
|
"summary": f"{len(hits)} note(s) trouvée(s)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def memory_save(title: str, content: str) -> dict:
|
||||||
|
"""Enregistre une note durable (préférence, décision, fait à retenir)."""
|
||||||
|
from . import memory_notes
|
||||||
|
|
||||||
|
result = memory_notes.save_note(title, content)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise ToolError(result["summary"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
_GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"}
|
_GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"}
|
||||||
_MAX_GREP_MATCHES = 50
|
_MAX_GREP_MATCHES = 50
|
||||||
|
|
||||||
@@ -645,6 +669,8 @@ TOOL_IMPL = {
|
|||||||
"web_search": web_search,
|
"web_search": web_search,
|
||||||
"run_shell": run_shell,
|
"run_shell": run_shell,
|
||||||
"run_check": run_check,
|
"run_check": run_check,
|
||||||
|
"memory_search": memory_search,
|
||||||
|
"memory_save": memory_save,
|
||||||
}
|
}
|
||||||
|
|
||||||
TOOL_DEFINITIONS = [
|
TOOL_DEFINITIONS = [
|
||||||
@@ -823,6 +849,43 @@ TOOL_DEFINITIONS = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_search",
|
||||||
|
"description": (
|
||||||
|
"Chercher dans tes notes durables (préférences de l'utilisateur, "
|
||||||
|
"décisions, faits appris lors d'anciennes discussions)."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "Ce que tu cherches"}
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "memory_save",
|
||||||
|
"description": (
|
||||||
|
"Enregistrer une note durable, réutilisable dans les prochaines "
|
||||||
|
"discussions. À réserver aux informations stables et utiles "
|
||||||
|
"(préférence, choix technique, contrainte). Jamais le détail "
|
||||||
|
"d'une tâche en cours."
|
||||||
|
),
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "Titre court et parlant"},
|
||||||
|
"content": {"type": "string", "description": "La note, concise"},
|
||||||
|
},
|
||||||
|
"required": ["title", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ export interface AgentConfig {
|
|||||||
embed_model: string;
|
embed_model: string;
|
||||||
skills_enabled: boolean;
|
skills_enabled: boolean;
|
||||||
ponytail: boolean;
|
ponytail: boolean;
|
||||||
|
memory_mode: "off" | "ondemand" | "always";
|
||||||
keep_alive: string;
|
keep_alive: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +407,44 @@ export async function getConfig(model?: string): Promise<{
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Presets de configuration ─────────────────────────────────────────────
|
||||||
|
export async function listPresets(): Promise<string[]> {
|
||||||
|
const res = await fetch("/api/config/presets");
|
||||||
|
return (await res.json()).presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePreset(name: string, model?: string): Promise<string[]> {
|
||||||
|
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
||||||
|
const res = await fetch(`/api/config/presets${query}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await apiError(res, "preset refusé");
|
||||||
|
return (await res.json()).presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPreset(
|
||||||
|
name: string,
|
||||||
|
model?: string
|
||||||
|
): Promise<AgentConfig> {
|
||||||
|
const query = model ? `?model=${encodeURIComponent(model)}` : "";
|
||||||
|
const res = await fetch(`/api/config/presets/apply${query}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw await apiError(res, "preset introuvable");
|
||||||
|
return (await res.json()).config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePreset(name: string): Promise<string[]> {
|
||||||
|
const res = await fetch(`/api/config/presets?name=${encodeURIComponent(name)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
return (await res.json()).presets;
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveConfig(
|
export async function saveConfig(
|
||||||
patch: Partial<AgentConfig>,
|
patch: Partial<AgentConfig>,
|
||||||
model?: string
|
model?: string
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStore } from "../store/useStore";
|
import { useStore } from "../store/useStore";
|
||||||
import {
|
import {
|
||||||
|
applyPreset,
|
||||||
deleteModel,
|
deleteModel,
|
||||||
getBenchScores,
|
getBenchScores,
|
||||||
getHardware,
|
getHardware,
|
||||||
listMcp,
|
listMcp,
|
||||||
|
listPresets,
|
||||||
pullModel,
|
pullModel,
|
||||||
runBench,
|
runBench,
|
||||||
|
savePreset,
|
||||||
testMcp,
|
testMcp,
|
||||||
updateMcp,
|
updateMcp,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
@@ -125,6 +128,7 @@ export function SettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<PresetBar model={selectedModel} onApplied={refreshConfig} />
|
||||||
<div className="flex items-center gap-1.5 text-[13px] text-ok">
|
<div className="flex items-center gap-1.5 text-[13px] text-ok">
|
||||||
<span className="h-2 w-2 border-2 border-line bg-ok" />
|
<span className="h-2 w-2 border-2 border-line bg-ok" />
|
||||||
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
||||||
@@ -459,8 +463,6 @@ export function SettingsView() {
|
|||||||
"Méthodes expertes selon la tâche (débogage, web…)"] as const,
|
"Méthodes expertes selon la tâche (débogage, web…)"] as const,
|
||||||
["self_review", "Auto-critique",
|
["self_review", "Auto-critique",
|
||||||
"Relit et révise la réponse — plus lent"] as const,
|
"Relit et révise la réponse — plus lent"] as const,
|
||||||
["rag_enabled", "Mémoire entre sessions",
|
|
||||||
"Rappelle d'AUTRES discussions ici. Chaque discussion garde toujours sa propre mémoire."] as const,
|
|
||||||
]).map(([key, label, desc], i) => (
|
]).map(([key, label, desc], i) => (
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
key={key}
|
key={key}
|
||||||
@@ -471,6 +473,29 @@ export function SettingsView() {
|
|||||||
onClick={() => set(key, !draft[key])}
|
onClick={() => set(key, !draft[key])}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<div className="flex items-center gap-3 border-t-2 border-line-soft py-2">
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="block text-[14px] text-ink">Mémoire</span>
|
||||||
|
<span className="block text-[12px] text-muted-2">
|
||||||
|
Notes Markdown que l'agent écrit lui-même, relisibles
|
||||||
|
dans <code>/data/MEMORY</code>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={draft.memory_mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
set(
|
||||||
|
"memory_mode",
|
||||||
|
e.target.value as AgentConfig["memory_mode"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-8 border-[3px] border-line bg-card px-2 text-[13px] text-ink"
|
||||||
|
>
|
||||||
|
<option value="off">Désactivée</option>
|
||||||
|
<option value="ondemand">Sur demande</option>
|
||||||
|
<option value="always">Automatique</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</Advanced>
|
</Advanced>
|
||||||
<div className="mt-2 flex items-center gap-3 border-t-2 border-line-soft pt-3">
|
<div className="mt-2 flex items-center gap-3 border-t-2 border-line-soft pt-3">
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
@@ -898,6 +923,75 @@ function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Presets : jeux de réglages nommés, enregistrés et rappelés en un clic. */
|
||||||
|
function PresetBar({
|
||||||
|
model,
|
||||||
|
onApplied,
|
||||||
|
}: {
|
||||||
|
model: string;
|
||||||
|
onApplied: () => void;
|
||||||
|
}) {
|
||||||
|
const [presets, setPresets] = useState<string[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listPresets().then(setPresets).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const apply = async (name: string) => {
|
||||||
|
if (!name) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await applyPreset(name, model || undefined);
|
||||||
|
onApplied();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
const name = window.prompt("Nom du preset (réglages actuels) :");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setPresets(await savePreset(name.trim(), model || undefined));
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : "preset refusé");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
disabled={busy || presets.length === 0}
|
||||||
|
onChange={(e) => apply(e.target.value)}
|
||||||
|
className="h-[34px] border-[3px] border-line bg-card px-2 text-[13px] text-ink disabled:opacity-40"
|
||||||
|
title="Appliquer un preset"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{presets.length ? "Preset…" : "Aucun preset"}
|
||||||
|
</option>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={create}
|
||||||
|
disabled={busy}
|
||||||
|
className="h-[34px] border-[3px] border-line bg-card px-2.5 text-[13px] text-muted-2 disabled:opacity-40"
|
||||||
|
title="Enregistrer les réglages actuels comme preset"
|
||||||
|
>
|
||||||
|
+ Preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Repli « Avancé » : sort les réglages rares du chemin principal.
|
/** Repli « Avancé » : sort les réglages rares du chemin principal.
|
||||||
*
|
*
|
||||||
* <details> natif : pas d'état à gérer, repliement mémorisé par le navigateur
|
* <details> natif : pas d'état à gérer, repliement mémorisé par le navigateur
|
||||||
|
|||||||
Reference in new issue
Block a user