mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
feat(projets): racine active par contextvar + projet par session
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
25a88dd879
commit
8d0b57ad0d
5 files changed
+123
-16
No files matched your search
+17
-5
@@ -58,6 +58,9 @@ def init_db() -> None:
|
|||||||
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
|
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
|
||||||
if "summary" not in scols:
|
if "summary" not in scols:
|
||||||
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
|
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
|
||||||
|
# Migration douce : projet (sous-dossier de travail) de la session.
|
||||||
|
if "project" not in scols:
|
||||||
|
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
|
||||||
# Table clé/valeur pour la configuration de l'agent.
|
# Table clé/valeur pour la configuration de l'agent.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
|
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
|
||||||
@@ -69,19 +72,28 @@ def _now() -> float:
|
|||||||
|
|
||||||
|
|
||||||
# ── Sessions ─────────────────────────────────────────────────────────────
|
# ── Sessions ─────────────────────────────────────────────────────────────
|
||||||
def create_session(title: str, model: str | None) -> dict:
|
def create_session(
|
||||||
|
title: str, model: str | None, project: str | None = None
|
||||||
|
) -> dict:
|
||||||
sid = uuid.uuid4().hex
|
sid = uuid.uuid4().hex
|
||||||
now = _now()
|
now = _now()
|
||||||
with _LOCK, _connect() as conn:
|
with _LOCK, _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sessions (id, title, model, created_at, updated_at)"
|
"INSERT INTO sessions (id, title, model, project, created_at, updated_at)"
|
||||||
" VALUES (?, ?, ?, ?, ?)",
|
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(sid, title, model, now, now),
|
(sid, title, model, project, now, now),
|
||||||
)
|
)
|
||||||
return {"id": sid, "title": title, "model": model,
|
return {"id": sid, "title": title, "model": model, "project": project,
|
||||||
"created_at": now, "updated_at": now, "message_count": 0}
|
"created_at": now, "updated_at": now, "message_count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_project(sid: str, project: str | None) -> None:
|
||||||
|
with _LOCK, _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_sessions() -> list[dict]:
|
def list_sessions() -> list[dict]:
|
||||||
with _LOCK, _connect() as conn:
|
with _LOCK, _connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ 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
|
from .. import agent_config, coder, db, enhance, memory, 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
|
||||||
@@ -87,7 +87,7 @@ def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
|
|||||||
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
|
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
|
||||||
par défaut pour Aider.
|
par défaut pour Aider.
|
||||||
"""
|
"""
|
||||||
root = os.path.abspath(settings.workspace_dir)
|
root = tools.active_root()
|
||||||
files: list[str] = []
|
files: list[str] = []
|
||||||
for m in history:
|
for m in history:
|
||||||
if m["role"] != "assistant":
|
if m["role"] != "assistant":
|
||||||
@@ -121,8 +121,8 @@ def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _workspace_listing(limit: int = 40) -> list[str]:
|
def _workspace_listing(limit: int = 40) -> list[str]:
|
||||||
"""Chemins relatifs des fichiers du workspace (aperçu compact)."""
|
"""Chemins relatifs des fichiers de la racine active (aperçu compact)."""
|
||||||
root = os.path.abspath(settings.workspace_dir)
|
root = tools.active_root()
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for dirpath, dirnames, filenames in os.walk(root):
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
@@ -145,7 +145,7 @@ def _mentioned_files(text: str) -> list[str]:
|
|||||||
Transmis au moteur code pour qu'Aider travaille directement sur les bons
|
Transmis au moteur code pour qu'Aider travaille directement sur les bons
|
||||||
fichiers au lieu de deviner via la repo map.
|
fichiers au lieu de deviner via la repo map.
|
||||||
"""
|
"""
|
||||||
root = os.path.abspath(settings.workspace_dir)
|
root = tools.active_root()
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for raw in _FILE_MENTION.findall(text):
|
for raw in _FILE_MENTION.findall(text):
|
||||||
rel = raw.replace("\\", "/").lstrip("./")
|
rel = raw.replace("\\", "/").lstrip("./")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import db
|
from .. import db, tools
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||||
|
|
||||||
@@ -12,10 +12,12 @@ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
|||||||
class CreateSession(BaseModel):
|
class CreateSession(BaseModel):
|
||||||
title: str = "Nouvelle session"
|
title: str = "Nouvelle session"
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
project: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RenameSession(BaseModel):
|
class UpdateSession(BaseModel):
|
||||||
title: str
|
title: str | None = None
|
||||||
|
project: str | None = None # "" = retour à la racine du workspace
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
@@ -25,7 +27,9 @@ async def get_sessions() -> dict:
|
|||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
async def post_session(req: CreateSession) -> dict:
|
async def post_session(req: CreateSession) -> dict:
|
||||||
return db.create_session(req.title, req.model)
|
if req.project and not tools.PROJECT_NAME.match(req.project):
|
||||||
|
raise HTTPException(400, "nom de projet invalide")
|
||||||
|
return db.create_session(req.title, req.model, req.project or None)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{sid}")
|
@router.get("/{sid}")
|
||||||
@@ -37,10 +41,16 @@ async def get_one(sid: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
@router.patch("/{sid}")
|
@router.patch("/{sid}")
|
||||||
async def patch_session(sid: str, req: RenameSession) -> dict:
|
async def patch_session(sid: str, req: UpdateSession) -> dict:
|
||||||
if not db.get_session(sid):
|
if not db.get_session(sid):
|
||||||
raise HTTPException(404, "session introuvable")
|
raise HTTPException(404, "session introuvable")
|
||||||
db.rename_session(sid, req.title)
|
if req.title is not None:
|
||||||
|
db.rename_session(sid, req.title)
|
||||||
|
if req.project is not None:
|
||||||
|
project = req.project or None
|
||||||
|
if project and not tools.PROJECT_NAME.match(project):
|
||||||
|
raise HTTPException(400, "nom de projet invalide")
|
||||||
|
db.set_session_project(sid, project)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from contextvars import ContextVar
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -25,8 +26,30 @@ class ToolError(Exception):
|
|||||||
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
|
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
|
||||||
|
|
||||||
|
|
||||||
|
# Projet actif pour la requête en cours : re-racine tous les outils sur
|
||||||
|
# workspace/<projet>. None = racine du workspace (comportement historique).
|
||||||
|
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
|
||||||
|
|
||||||
|
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def set_project(name: str | None) -> None:
|
||||||
|
"""Fixe le projet actif de la requête (None = racine)."""
|
||||||
|
if name is not None and not PROJECT_NAME.match(name):
|
||||||
|
raise ToolError(f"nom de projet invalide : {name!r}")
|
||||||
|
_ACTIVE_PROJECT.set(name)
|
||||||
|
|
||||||
|
|
||||||
|
def active_root() -> str:
|
||||||
|
"""Racine effective (workspace ou projet), créée si nécessaire."""
|
||||||
|
return _workspace_root()
|
||||||
|
|
||||||
|
|
||||||
def _workspace_root() -> str:
|
def _workspace_root() -> str:
|
||||||
root = os.path.abspath(settings.workspace_dir)
|
root = os.path.abspath(settings.workspace_dir)
|
||||||
|
project = _ACTIVE_PROJECT.get()
|
||||||
|
if project:
|
||||||
|
root = os.path.join(root, project)
|
||||||
os.makedirs(root, exist_ok=True)
|
os.makedirs(root, exist_ok=True)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
|
||||||
|
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
|
||||||
|
from app import db, tools # noqa: E402
|
||||||
|
from app.config import settings # noqa: E402
|
||||||
|
|
||||||
|
db.init_db()
|
||||||
|
_ROOT = os.path.abspath(settings.workspace_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_project():
|
||||||
|
tools.set_project(None)
|
||||||
|
yield
|
||||||
|
tools.set_project(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_racine_par_defaut():
|
||||||
|
assert tools.active_root() == _ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_project_reracine():
|
||||||
|
tools.set_project("demo")
|
||||||
|
root = tools.active_root()
|
||||||
|
assert root == os.path.join(_ROOT, "demo")
|
||||||
|
assert os.path.isdir(root) # créée à la volée
|
||||||
|
|
||||||
|
|
||||||
|
def test_confinement_conserve():
|
||||||
|
tools.set_project("demo")
|
||||||
|
with pytest.raises(tools.ToolError):
|
||||||
|
tools._safe_path("../hors-projet")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nom_projet_invalide():
|
||||||
|
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
|
||||||
|
with pytest.raises(tools.ToolError):
|
||||||
|
tools.set_project(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_porte_son_projet():
|
||||||
|
s = db.create_session("t", None, project="demo")
|
||||||
|
assert db.get_session(s["id"])["project"] == "demo"
|
||||||
|
db.set_session_project(s["id"], None)
|
||||||
|
assert db.get_session(s["id"])["project"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_aides_contexte_suivent_le_projet():
|
||||||
|
from app.routes.chat import _mentioned_files, _workspace_listing
|
||||||
|
tools.set_project("ctxdemo")
|
||||||
|
root = tools.active_root()
|
||||||
|
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
|
||||||
|
f.write("x = 1")
|
||||||
|
assert "app.py" in _workspace_listing()
|
||||||
|
assert _mentioned_files("corrige app.py") == ["app.py"]
|
||||||
|
tools.set_project(None)
|
||||||
|
assert _mentioned_files("corrige app.py") == [] # absent de la racine
|
||||||
Reference in new issue
Block a user