Phase 3 : chat streaming SSE et persistance des sessions

Backend :
- db.py : SQLite (sessions + messages), schéma et CRUD
- routes/sessions.py : créer/lister/ouvrir/renommer/supprimer une session
- routes/chat.py : POST /api/chat, relais token par token depuis Ollama (SSE),
  persistance des messages, titrage auto de la session au 1er message
- main.py : lifespan -> init_db, montage des nouvelles routes

Frontend :
- api/client.ts : APIs sessions + streamChat (parsing SSE event/data)
- store : sessions, messages, état de streaming, envoi optimiste
- ChatPanel : fil de conversation réel, bulles user/agent, curseur de frappe
- LeftPanel : historique réel (ouvrir/supprimer, horodatage relatif)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-29 18:46:51 +00:00
1 parent 3ad37ae2c7
commit 48cbcaafaf
9 files changed
+688 -50

No files matched your search

+4 -2
View File
@@ -15,7 +15,9 @@ Frontend (React + Vite + TS + Tailwind)
│ HTTP + SSE │ HTTP + SSE
Backend (FastAPI, Python) Backend (FastAPI, Python)
├── /api/status, /api/models, /api/models/pull (Ollama) ├── /api/status, /api/models, /api/models/pull (Ollama)
│ (chat agentique & outils : phases suivantes) ├── /api/sessions (CRUD sessions)
├── /api/chat (conversation streaming, SSE)
│ (outils agentiques : phases suivantes)
│ httpx │ volume │ httpx │ volume
Ollama (:11434) /workspace + /data (SQLite) Ollama (:11434) /workspace + /data (SQLite)
``` ```
@@ -67,7 +69,7 @@ npm run dev # http://localhost:5173
- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux - [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux
- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur - [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur
- [ ] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite) - [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite)
- [ ] **Phase 4** — Boucle agentique & outils fichiers (read/write/list) - [ ] **Phase 4** — Boucle agentique & outils fichiers (read/write/list)
- [ ] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence - [ ] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence
- [ ] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système) - [ ] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système)
+135
View File
@@ -0,0 +1,135 @@
"""Persistance SQLite : sessions et messages.
On utilise sqlite3 de la bibliothèque standard (zéro dépendance). Les écritures
sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
"""
from __future__ import annotations
import os
import sqlite3
import threading
import time
import uuid
from .config import settings
_LOCK = threading.Lock()
_DB_PATH = os.path.join(settings.data_dir, "loki.db")
def _connect() -> sqlite3.Connection:
os.makedirs(settings.data_dir, exist_ok=True)
conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
"""Crée les tables si elles n'existent pas."""
with _LOCK, _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, created_at);
"""
)
def _now() -> float:
return time.time()
# ── Sessions ─────────────────────────────────────────────────────────────
def create_session(title: str, model: str | None) -> dict:
sid = uuid.uuid4().hex
now = _now()
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO sessions (id, title, model, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)",
(sid, title, model, now, now),
)
return {"id": sid, "title": title, "model": model,
"created_at": now, "updated_at": now, "message_count": 0}
def list_sessions() -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY s.updated_at DESC
"""
).fetchall()
return [dict(r) for r in rows]
def get_session(sid: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
return dict(row) if row else None
def rename_session(sid: str, title: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
(title, _now(), sid),
)
def delete_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
def touch_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), sid)
)
# ── Messages ─────────────────────────────────────────────────────────────
def add_message(sid: str, role: str, content: str, model: str | None) -> dict:
mid = uuid.uuid4().hex
now = _now()
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO messages (id, session_id, role, content, model, created_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(mid, sid, role, content, model, now),
)
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
)
return {"id": mid, "session_id": sid, "role": role, "content": content,
"model": model, "created_at": now}
def list_messages(sid: str) -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
(sid,),
).fetchall()
return [dict(r) for r in rows]
+15 -2
View File
@@ -5,16 +5,27 @@ Sert l'API (/api/*) et, en production, le frontend React compilé (static/).
from __future__ import annotations from __future__ import annotations
import os import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from . import db
from .config import settings from .config import settings
from .routes import models from .routes import chat, models, sessions
app = FastAPI(title="Loki", description="Agent IA local sur Ollama")
@asynccontextmanager
async def lifespan(_: FastAPI):
db.init_db()
yield
app = FastAPI(
title="Loki", description="Agent IA local sur Ollama", lifespan=lifespan
)
# En dev, le front tourne sur Vite (5173). On autorise le CORS large ; # 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. # en prod le front est servi par le même origin, donc sans impact.
@@ -26,6 +37,8 @@ app.add_middleware(
) )
app.include_router(models.router) app.include_router(models.router)
app.include_router(sessions.router)
app.include_router(chat.router)
@app.get("/api/health") @app.get("/api/health")
+84
View File
@@ -0,0 +1,84 @@
"""Route de conversation : streaming token par token depuis Ollama (SSE).
Flux :
1. on enregistre le message utilisateur ;
2. on rejoue l'historique de la session vers Ollama en streaming ;
3. on relaie chaque token au client (SSE) ;
4. on enregistre la réponse complète de l'assistant.
"""
from __future__ import annotations
import json
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import db
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["chat"])
SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local. Tu écris du code clair, "
"commenté en français, et tu réponds de façon concise et utile."
)
class ChatRequest(BaseModel):
session_id: str
content: str
model: str | None = None
options: dict | None = None
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
if not session:
raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model
# Premier message : on titre la session avec un extrait.
history = db.list_messages(req.session_id)
if not history:
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
db.rename_session(req.session_id, title)
db.add_message(req.session_id, "user", req.content, None)
# Contexte envoyé à Ollama : invite système + historique complet.
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
convo += [
{"role": m["role"], "content": m["content"]}
for m in db.list_messages(req.session_id)
]
async def event_stream():
yield _sse("start", {"model": model})
full = ""
try:
async for chunk in ollama.chat(
model, convo, options=req.options, stream=True
):
token = chunk.get("message", {}).get("content", "")
if token:
full += token
yield _sse("token", {"content": token})
if chunk.get("done"):
break
except (httpx.HTTPError, OSError) as exc:
yield _sse("error", {"message": str(exc)})
if full:
db.add_message(req.session_id, "assistant", full, model)
yield _sse("done", {"content": full, "model": model})
return StreamingResponse(event_stream(), media_type="text/event-stream")
+50
View File
@@ -0,0 +1,50 @@
"""Routes de gestion des sessions de conversation."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import db
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
class RenameSession(BaseModel):
title: str
@router.get("")
async def get_sessions() -> dict:
return {"sessions": db.list_sessions()}
@router.post("")
async def post_session(req: CreateSession) -> dict:
return db.create_session(req.title, req.model)
@router.get("/{sid}")
async def get_one(sid: str) -> dict:
session = db.get_session(sid)
if not session:
raise HTTPException(404, "session introuvable")
return {"session": session, "messages": db.list_messages(sid)}
@router.patch("/{sid}")
async def patch_session(sid: str, req: RenameSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
db.rename_session(sid, req.title)
return {"ok": True}
@router.delete("/{sid}")
async def remove_session(sid: str) -> dict:
db.delete_session(sid)
return {"ok": True}
+93
View File
@@ -29,6 +29,99 @@ export async function getModels(): Promise<{
return res.json(); return res.json();
} }
export interface Session {
id: string;
title: string;
model?: string;
created_at: number;
updated_at: number;
message_count?: number;
}
export interface Message {
id: string;
session_id: string;
role: "user" | "assistant";
content: string;
model?: string;
created_at: number;
}
export async function listSessions(): Promise<Session[]> {
const res = await fetch("/api/sessions");
return (await res.json()).sessions;
}
export async function createSession(model?: string): Promise<Session> {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Nouvelle session", model }),
});
return res.json();
}
export async function getSession(
id: string
): Promise<{ session: Session; messages: Message[] }> {
const res = await fetch(`/api/sessions/${id}`);
return res.json();
}
export async function deleteSession(id: string): Promise<void> {
await fetch(`/api/sessions/${id}`, { method: "DELETE" });
}
/** Envoie un message et streame la réponse de l'agent via SSE. */
export async function streamChat(
body: { session_id: string; content: string; model?: string },
handlers: {
onToken: (t: string) => void;
onDone: (full: string) => void;
onError: (msg: string) => void;
}
): Promise<void> {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.body) {
handlers.onError("pas de flux de réponse");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
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 = "message";
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 === "token") handlers.onToken(payload.content);
else if (event === "done") handlers.onDone(payload.content);
else if (event === "error") handlers.onError(payload.message);
} catch {
/* bloc partiel */
}
}
}
}
/** Télécharge un modèle en streamant la progression via SSE. */ /** Télécharge un modèle en streamant la progression via SSE. */
export async function pullModel( export async function pullModel(
name: string, name: string,
+131 -22
View File
@@ -1,9 +1,41 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import { ClipIcon, LokiMark, SendIcon } from "../components/Icon"; import { ClipIcon, LokiMark, SendIcon } from "../components/Icon";
import type { Message } from "../api/client";
/** Panneau central : barre de contexte, fil de conversation, composer. */ /** Panneau central : barre de contexte, fil de conversation, composer. */
export function ChatPanel() { export function ChatPanel() {
const selectedModel = useStore((s) => s.selectedModel); const {
selectedModel,
messages,
streaming,
streamContent,
sendMessage,
currentSessionId,
} = useStore();
const [draft, setDraft] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll vers le bas à chaque token / message.
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages, streamContent]);
const submit = () => {
if (!draft.trim() || streaming) return;
sendMessage(draft);
setDraft("");
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const empty = messages.length === 0 && !streaming;
return ( return (
<div className="flex min-w-0 flex-1 flex-col bg-base"> <div className="flex min-w-0 flex-1 flex-col bg-base">
@@ -16,29 +48,47 @@ export function ChatPanel() {
<Chip> <Chip>
Température <b className="font-semibold text-[#e7ddcd]">0.7</b> Température <b className="font-semibold text-[#e7ddcd]">0.7</b>
</Chip> </Chip>
<Chip>
Contexte
<span className="h-[5px] w-[46px] overflow-hidden rounded bg-line-strong">
<span className="block h-full w-0 bg-accent" />
</span>
<span className="font-mono text-[10.5px] text-muted-2">0/128K</span>
</Chip>
<div className="flex-1" /> <div className="flex-1" />
<span className="font-mono text-[11px] text-muted-4">≈ 0 jeton</span> <span className="font-mono text-[11px] text-muted-4">
{currentSessionId
? `${messages.length} message${messages.length > 1 ? "s" : ""}`
: "aucune session"}
</span>
</div> </div>
{/* Messages (vide pour l'instant — phase 3) */} {/* Messages */}
<div className="scr flex-1 overflow-auto px-7 py-6"> <div ref={scrollRef} className="scr flex-1 overflow-auto px-7 py-6">
<div className="mx-auto flex max-w-[680px] flex-col items-center justify-center gap-4 pt-24 text-center"> {empty ? (
<LokiMark size={44} /> <div className="mx-auto flex max-w-[680px] flex-col items-center justify-center gap-4 pt-24 text-center">
<div className="text-lg font-semibold text-ink"> <LokiMark size={44} />
Prêt à travailler. <div className="text-lg font-semibold text-ink">
Prêt à travailler.
</div>
<div className="max-w-[360px] text-sm leading-relaxed text-muted">
Décris une tâche à l'agent. Les outils fichiers et l'aperçu en
direct arrivent à la prochaine étape.
</div>
</div> </div>
<div className="max-w-[360px] text-sm leading-relaxed text-muted"> ) : (
Décris une tâche à l'agent. Il pourra lire et écrire des fichiers <div className="mx-auto flex max-w-[680px] flex-col gap-[22px]">
dans le workspace, et te montrer un aperçu en direct. {messages.map((m) => (
<Bubble key={m.id} msg={m} />
))}
{streaming && (
<Bubble
msg={{
id: "stream",
session_id: "",
role: "assistant",
content: streamContent,
model: selectedModel,
created_at: Date.now() / 1000,
}}
pending
/>
)}
</div> </div>
</div> )}
</div> </div>
{/* Composer */} {/* Composer */}
@@ -47,6 +97,9 @@ export function ChatPanel() {
<div className="rounded-[14px] border border-line-strong bg-card-soft p-[13px]"> <div className="rounded-[14px] border border-line-strong bg-card-soft p-[13px]">
<textarea <textarea
rows={1} rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Envoyer un message à l'agent…" placeholder="Envoyer un message à l'agent…"
className="min-h-[42px] w-full resize-none bg-transparent text-sm leading-relaxed text-ink outline-none placeholder:text-muted-3" className="min-h-[42px] w-full resize-none bg-transparent text-sm leading-relaxed text-ink outline-none placeholder:text-muted-3"
/> />
@@ -62,9 +115,13 @@ export function ChatPanel() {
<span className="font-mono text-[10.5px] text-muted-4"> <span className="font-mono text-[10.5px] text-muted-4">
⏎ envoyer · ⇧⏎ ligne ⏎ envoyer · ⇧⏎ ligne
</span> </span>
<button className="flex h-[34px] items-center gap-1.5 rounded-[9px] bg-accent px-4 text-[13px] font-bold text-[#1f1b16]"> <button
Envoyer onClick={submit}
<SendIcon /> disabled={streaming || !draft.trim()}
className="flex h-[34px] items-center gap-1.5 rounded-[9px] bg-accent px-4 text-[13px] font-bold text-[#1f1b16] disabled:opacity-50"
>
{streaming ? "…" : "Envoyer"}
{!streaming && <SendIcon />}
</button> </button>
</div> </div>
</div> </div>
@@ -74,6 +131,58 @@ export function ChatPanel() {
); );
} }
function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
});
if (msg.role === "user") {
return (
<div className="flex gap-3">
<div className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-[9px] bg-[#3a332b] text-xs font-semibold text-ink-2">
M
</div>
<div className="flex-1">
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-[13px] font-semibold">Vous</span>
<span className="font-mono text-[10.5px] text-muted-4">{time}</span>
</div>
<div className="rounded-xl rounded-tl-[4px] border border-line bg-card-soft px-[15px] py-3 text-sm leading-relaxed text-ink-2 whitespace-pre-wrap">
{msg.content}
</div>
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-[9px] bg-accent-grad">
<div
className="bg-base"
style={{ width: 8, height: 8, borderRadius: 2, transform: "rotate(45deg)" }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-[13px] font-semibold">Agent</span>
<span className="font-mono text-[10.5px] text-muted-4">
{msg.model ? `${msg.model} · ` : ""}
{time}
</span>
</div>
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
{msg.content}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
</div>
</div>
</div>
);
}
function Chip({ children }: { children: React.ReactNode }) { function Chip({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-[25px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-card-soft px-[9px] text-[11.5px] text-ink-3"> <div className="flex h-[25px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-card-soft px-[9px] text-[11.5px] text-ink-3">
+72 -24
View File
@@ -1,11 +1,22 @@
import { useEffect } from "react";
import { useStore } from "../store/useStore";
import { PlusIcon } from "../components/Icon"; import { PlusIcon } from "../components/Icon";
const sessions = [
{ title: "Nouvelle session", meta: "à l'instant · 0 message", active: true },
];
/** Panneau gauche : historique des sessions + arborescence de fichiers. */ /** Panneau gauche : historique des sessions + arborescence de fichiers. */
export function LeftPanel() { export function LeftPanel() {
const {
sessions,
currentSessionId,
refreshSessions,
newSession,
openSession,
removeSession,
} = useStore();
useEffect(() => {
refreshSessions();
}, [refreshSessions]);
return ( return (
<div className="flex w-[268px] flex-none flex-col border-r border-line-soft bg-panel"> <div className="flex w-[268px] flex-none flex-col border-r border-line-soft bg-panel">
{/* Historique */} {/* Historique */}
@@ -13,31 +24,60 @@ export function LeftPanel() {
<span className="text-[11px] font-bold uppercase tracking-[.07em] text-label"> <span className="text-[11px] font-bold uppercase tracking-[.07em] text-label">
Historique Historique
</span> </span>
<span className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-accent"> <button
onClick={newSession}
className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-accent"
>
<PlusIcon /> <PlusIcon />
Nouvelle Nouvelle
</span> </button>
</div> </div>
<div className="flex flex-col gap-0.5 px-2.5"> <div className="scr flex max-h-[45%] flex-col gap-0.5 overflow-auto px-2.5">
{sessions.map((s, i) => ( {sessions.length === 0 && (
<div <div className="px-3 py-3 text-xs text-muted-3">
key={i} Aucune session. Lance une conversation ci-dessous.
className={`relative cursor-pointer rounded-[9px] px-3 py-[9px] ${
s.active ? "bg-[#252019]" : ""
}`}
>
{s.active && (
<div className="absolute bottom-[9px] left-0 top-[9px] w-[3px] rounded bg-accent" />
)}
<div className="truncate text-[13px] font-semibold text-ink">
{s.title}
</div>
<div className="mt-[3px] font-mono text-[10.5px] text-muted-3">
{s.meta}
</div>
</div> </div>
))} )}
{sessions.map((s) => {
const active = s.id === currentSessionId;
return (
<div
key={s.id}
onClick={() => openSession(s.id)}
className={`group relative cursor-pointer rounded-[9px] px-3 py-[9px] ${
active ? "bg-[#252019]" : "hover:bg-[#221e18]"
}`}
>
{active && (
<div className="absolute bottom-[9px] left-0 top-[9px] w-[3px] rounded bg-accent" />
)}
<div className="flex items-center gap-1">
<div
className={`flex-1 truncate text-[13px] ${
active ? "font-semibold text-ink" : "font-medium text-ink-3"
}`}
>
{s.title}
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeSession(s.id);
}}
className="hidden text-muted-3 hover:text-warn group-hover:block"
title="Supprimer"
>
×
</button>
</div>
<div className="mt-[3px] font-mono text-[10.5px] text-muted-3">
{relTime(s.updated_at)} · {s.message_count ?? 0} message
{(s.message_count ?? 0) > 1 ? "s" : ""}
</div>
</div>
);
})}
</div> </div>
<div className="mx-4 my-3.5 h-px bg-line-soft" /> <div className="mx-4 my-3.5 h-px bg-line-soft" />
@@ -60,3 +100,11 @@ export function LeftPanel() {
</div> </div>
); );
} }
function relTime(ts: number): string {
const diff = Date.now() / 1000 - ts;
if (diff < 60) return "à l'instant";
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`;
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`;
return `il y a ${Math.floor(diff / 86400)} j`;
}
+104
View File
@@ -1,9 +1,16 @@
import { create } from "zustand"; import { create } from "zustand";
import { import {
createSession,
deleteSession,
getModels, getModels,
getSession,
getStatus, getStatus,
listSessions,
streamChat,
type Message,
type OllamaModel, type OllamaModel,
type OllamaStatus, type OllamaStatus,
type Session,
} from "../api/client"; } from "../api/client";
interface LokiState { interface LokiState {
@@ -12,9 +19,21 @@ interface LokiState {
selectedModel: string; selectedModel: string;
loadingModels: boolean; loadingModels: boolean;
sessions: Session[];
currentSessionId: string | null;
messages: Message[];
streaming: boolean;
streamContent: string; // réponse de l'agent en cours de frappe
setSelectedModel: (name: string) => void; setSelectedModel: (name: string) => void;
refreshStatus: () => Promise<void>; refreshStatus: () => Promise<void>;
refreshModels: () => Promise<void>; refreshModels: () => Promise<void>;
refreshSessions: () => Promise<void>;
newSession: () => Promise<void>;
openSession: (id: string) => Promise<void>;
removeSession: (id: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
} }
export const useStore = create<LokiState>((set, get) => ({ export const useStore = create<LokiState>((set, get) => ({
@@ -23,6 +42,12 @@ export const useStore = create<LokiState>((set, get) => ({
selectedModel: "", selectedModel: "",
loadingModels: false, loadingModels: false,
sessions: [],
currentSessionId: null,
messages: [],
streaming: false,
streamContent: "",
setSelectedModel: (name) => set({ selectedModel: name }), setSelectedModel: (name) => set({ selectedModel: name }),
refreshStatus: async () => { refreshStatus: async () => {
@@ -47,4 +72,83 @@ export const useStore = create<LokiState>((set, get) => ({
set({ loadingModels: false }); set({ loadingModels: false });
} }
}, },
refreshSessions: async () => {
const sessions = await listSessions();
set({ sessions });
},
newSession: async () => {
const s = await createSession(get().selectedModel || undefined);
set({ currentSessionId: s.id, messages: [], streamContent: "" });
await get().refreshSessions();
},
openSession: async (id) => {
const { messages } = await getSession(id);
set({ currentSessionId: id, messages, streamContent: "" });
},
removeSession: async (id) => {
await deleteSession(id);
if (get().currentSessionId === id) {
set({ currentSessionId: null, messages: [] });
}
await get().refreshSessions();
},
sendMessage: async (content) => {
if (get().streaming) return;
content = content.trim();
if (!content) return;
// Crée une session à la volée si aucune n'est ouverte.
let sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined);
sid = s.id;
set({ currentSessionId: s.id });
}
// Affichage optimiste du message utilisateur.
const userMsg: Message = {
id: `tmp-${Date.now()}`,
session_id: sid,
role: "user",
content,
created_at: Date.now() / 1000,
};
set({
messages: [...get().messages, userMsg],
streaming: true,
streamContent: "",
});
await streamChat(
{ session_id: sid, content, model: get().selectedModel || undefined },
{
onToken: (t) => set({ streamContent: get().streamContent + t }),
onDone: async () => {
set({ streaming: false, streamContent: "" });
// Recharge depuis la base pour récupérer les ids/horodatages réels.
if (get().currentSessionId === sid) await get().openSession(sid!);
await get().refreshSessions();
},
onError: (msg) => {
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: sid!,
role: "assistant",
content: `⚠️ Erreur : ${msg}`,
created_at: Date.now() / 1000,
};
set({
streaming: false,
streamContent: "",
messages: [...get().messages, errMsg],
});
},
}
);
},
})); }));