mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 11:49:43 +02:00
Phase 1-2 : socle Loki, design system et connexion Ollama
- Scaffold full-stack : backend FastAPI + frontend React/Vite/TS/Tailwind - Design system fidèle au thème (palette ambre/café, JetBrains Mono, tokens Tailwind) - Layout 3 panneaux : activity bar, historique/fichiers, chat, aperçu - Connexion Ollama : statut live, liste des modèles, pull avec progression SSE, sélecteur de modèle - Vue Configuration (Frame 2) : modèles, génération, outils, invite système - Dockerfile multi-stage + docker-compose (Ollama externe par défaut, profil ollama optionnel) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
34 files changed
+4343
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,17 @@
|
||||
"""Configuration de l'application, chargée depuis l'environnement."""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
|
||||
|
||||
ollama_host: str = "http://host.docker.internal:11434"
|
||||
default_model: str = "llama3.1:8b"
|
||||
workspace_dir: str = "/workspace"
|
||||
data_dir: str = "/data"
|
||||
port: int = 8080
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Point d'entrée FastAPI de Loki.
|
||||
|
||||
Sert l'API (/api/*) et, en production, le frontend React compilé (static/).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .config import settings
|
||||
from .routes import models
|
||||
|
||||
app = FastAPI(title="Loki", description="Agent IA local sur Ollama")
|
||||
|
||||
# En dev, le front tourne sur Vite (5173). On autorise le CORS large ;
|
||||
# en prod le front est servi par le même origin, donc sans impact.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(models.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "service": "loki"}
|
||||
|
||||
|
||||
# ── Service du frontend compilé (présent uniquement en image Docker) ─────
|
||||
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static")
|
||||
if os.path.isdir(_STATIC_DIR):
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=os.path.join(_STATIC_DIR, "assets")),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(full_path: str):
|
||||
"""Renvoie index.html pour toutes les routes (SPA React)."""
|
||||
index = os.path.join(_STATIC_DIR, "index.html")
|
||||
return FileResponse(index)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Client HTTP léger pour l'API Ollama.
|
||||
|
||||
On utilise httpx directement (plutôt que le SDK) pour garder le contrôle
|
||||
total sur le streaming et n'embarquer aucune dépendance superflue.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import AsyncIterator
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""Enveloppe asynchrone autour de l'API REST d'Ollama."""
|
||||
|
||||
def __init__(self, host: str | None = None) -> None:
|
||||
self.host = (host or settings.ollama_host).rstrip("/")
|
||||
|
||||
async def ping(self) -> dict:
|
||||
"""Vérifie la connexion et renvoie la version d'Ollama."""
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{self.host}/api/version")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_models(self) -> list[dict]:
|
||||
"""Liste les modèles installés localement (/api/tags)."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{self.host}/api/tags")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("models", [])
|
||||
|
||||
async def pull_model(self, name: str) -> AsyncIterator[dict]:
|
||||
"""Télécharge un modèle en streamant la progression (/api/pull)."""
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.host}/api/pull", json={"name": name}
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.strip():
|
||||
yield json.loads(line)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
*,
|
||||
tools: list[dict] | None = None,
|
||||
options: dict | None = None,
|
||||
stream: bool = True,
|
||||
) -> AsyncIterator[dict]:
|
||||
"""Conversation avec le modèle, en streaming token par token."""
|
||||
payload: dict = {"model": model, "messages": messages, "stream": stream}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if options:
|
||||
payload["options"] = options
|
||||
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{self.host}/api/chat", json=payload
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if line.strip():
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
ollama = OllamaClient()
|
||||
Whitespace-only changes.
@@ -0,0 +1,77 @@
|
||||
"""Routes liées à Ollama : statut de connexion, modèles, téléchargement."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
from ..ollama_client import ollama
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["ollama"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status() -> dict:
|
||||
"""État de la connexion Ollama (point vert/rouge de la barre supérieure)."""
|
||||
try:
|
||||
version = await ollama.ping()
|
||||
return {
|
||||
"connected": True,
|
||||
"host": ollama.host,
|
||||
"version": version.get("version"),
|
||||
"default_model": settings.default_model,
|
||||
}
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
return {
|
||||
"connected": False,
|
||||
"host": ollama.host,
|
||||
"error": str(exc),
|
||||
"default_model": settings.default_model,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models() -> dict:
|
||||
"""Modèles installés localement, formatés pour le sélecteur de l'UI."""
|
||||
try:
|
||||
raw = await ollama.list_models()
|
||||
except (httpx.HTTPError, OSError):
|
||||
return {"models": []}
|
||||
|
||||
models = []
|
||||
for m in raw:
|
||||
details = m.get("details", {})
|
||||
size_go = round(m.get("size", 0) / 1_000_000_000, 1)
|
||||
models.append(
|
||||
{
|
||||
"name": m.get("name"),
|
||||
"size_go": size_go,
|
||||
"parameter_size": details.get("parameter_size"),
|
||||
"quantization": details.get("quantization_level"),
|
||||
"family": details.get("family"),
|
||||
}
|
||||
)
|
||||
return {"models": models, "default": settings.default_model}
|
||||
|
||||
|
||||
class PullRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
@router.post("/models/pull")
|
||||
async def pull_model(req: PullRequest) -> StreamingResponse:
|
||||
"""Télécharge un modèle en streamant la progression (SSE)."""
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
async for chunk in ollama.pull_model(req.name):
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
pydantic==2.10.4
|
||||
pydantic-settings==2.7.1
|
||||
Reference in new issue
Block a user