mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Barre latérale de la maquette + contexte workspace conditionnel
Remaniement de la navigation selon « Loki App.dc.html » : le rail d'icônes de 60 px et le panneau de sessions laissent place à UNE barre latérale de 264 px — marque, navigation libellée (Demander, Historique, Fichiers, Outils), section Configuration, état Ollama avec jauge VRAM et carte de compte. La marque quitte la barre supérieure, qui ne garde que le contexte de la conversation. Les sessions restent accessibles par le sélecteur de la barre supérieure, et les fichiers par la vue Fichiers. Performance : l'inventaire du workspace n'est plus injecté à CHAQUE message, seulement quand la demande touche au code ou qu'un travail de code est en cours. Un simple « bonjour » partait avec des milliers de jetons de contexte, d'où une longue phase de traitement avant le premier jeton. L'attente avant le premier jeton affiche désormais le modèle en cours de chargement et le temps écoulé, au lieu de rester figée sur « Connexion à Ollama… » sans distinguer lenteur et blocage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
4 files changed
+195
-27
No files matched your search
@@ -14,6 +14,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -61,6 +62,19 @@ def _apply_mode(cfg: dict, mode: str) -> dict:
|
||||
return cfg
|
||||
|
||||
|
||||
def _loading_message(model: str, seconds: float) -> str:
|
||||
"""Message d'attente avant le premier jeton (chargement du modèle)."""
|
||||
waited = int(seconds)
|
||||
delay = f"{waited // 60} min" if waited >= 60 else f"{waited} s"
|
||||
text = f"Chargement de {model} en mémoire… ({delay})"
|
||||
if waited >= 90:
|
||||
# On CONSTATE l'attente sans présumer de la cause : selon la machine,
|
||||
# un gros modèle peut se répartir sur plusieurs GPU, ou déborder sur
|
||||
# le CPU. Le panneau Matériel donne le placement réel.
|
||||
text += " — placement réel visible dans Réglages › Matériel."
|
||||
return text
|
||||
|
||||
|
||||
def _merge_system(convo: list[dict], extras: list[str]) -> None:
|
||||
"""Garantit UN SEUL message système, en première position.
|
||||
|
||||
@@ -415,16 +429,22 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
# échouer toute la requête en 400.
|
||||
extras: list[str] = []
|
||||
|
||||
# La demande touche-t-elle au code / aux fichiers ? Calculé AVANT les
|
||||
# injections : tout ce qui suit est conditionné à cette réponse.
|
||||
is_code_like = use_code or msg_router.is_code_task(req.content)
|
||||
needs_workspace = is_code_like or prev_code
|
||||
|
||||
if memories:
|
||||
extras.append(
|
||||
"Souvenirs pertinents d'anciennes sessions :\n"
|
||||
+ "\n---\n".join(memories)
|
||||
)
|
||||
|
||||
# État du workspace injecté chaque tour : sans ça le modèle ignore
|
||||
# quels fichiers existent et régurgite du code en chat au lieu de
|
||||
# modifier le bon fichier.
|
||||
listing = _workspace_listing()
|
||||
# État du workspace : indispensable pour travailler sur les fichiers,
|
||||
# mais inutile pour une simple discussion. On l'injectait à CHAQUE
|
||||
# message — un « bonjour » partait avec des milliers de jetons de
|
||||
# contexte, donc une longue phase de traitement avant le 1er jeton.
|
||||
listing = _workspace_listing() if needs_workspace else []
|
||||
if listing:
|
||||
recap, session_files = _session_code_context(history)
|
||||
parts = ["Fichiers du workspace : " + ", ".join(listing)]
|
||||
@@ -458,7 +478,6 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
|
||||
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
|
||||
# (les deux chemins). Contre la sur-ingénierie qui casse les rendus.
|
||||
is_code_like = use_code or msg_router.is_code_task(req.content)
|
||||
if cfg.get("ponytail", True) and is_code_like:
|
||||
extras.append(skills.PONYTAIL_GUIDANCE)
|
||||
|
||||
@@ -574,6 +593,8 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
await queue.put(None)
|
||||
|
||||
producer = asyncio.create_task(produce_events())
|
||||
started = time.monotonic()
|
||||
got_event = False
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
@@ -582,8 +603,18 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
# Empêche OpenResty/Nginx/Cloudflare de fermer le SSE pendant
|
||||
# le chargement parfois long d'un modèle Ollama.
|
||||
yield _sse("ping", {"status": "waiting"})
|
||||
# …et DIT ce qui se passe : sans ça, l'interface restait sur
|
||||
# « Connexion à Ollama… » pendant tout le chargement d'un
|
||||
# gros modèle, sans moyen de distinguer lenteur et blocage.
|
||||
if not got_event:
|
||||
yield _sse("status", {
|
||||
"message": _loading_message(
|
||||
model, time.monotonic() - started
|
||||
)
|
||||
})
|
||||
continue
|
||||
|
||||
got_event = True
|
||||
if ev is None:
|
||||
break
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ActivityBar, type View } from "./components/ActivityBar";
|
||||
import { Sidebar, type View } from "./components/Sidebar";
|
||||
import { TopBar } from "./components/TopBar";
|
||||
import { LeftPanel } from "./panels/LeftPanel";
|
||||
import { ChatPanel } from "./panels/ChatPanel";
|
||||
import { PreviewPanel } from "./panels/PreviewPanel";
|
||||
import { SettingsView } from "./panels/SettingsView";
|
||||
@@ -57,11 +56,10 @@ export default function App() {
|
||||
<div className="flex h-full flex-col bg-base text-ink">
|
||||
<TopBar />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<ActivityBar active={view} onChange={setView} />
|
||||
<Sidebar active={view} onChange={setView} />
|
||||
|
||||
{view === "chat" && (
|
||||
<>
|
||||
<LeftPanel />
|
||||
<ChatPanel />
|
||||
<PreviewPanel />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useStore } from "../store/useStore";
|
||||
import {
|
||||
ChatIcon,
|
||||
ClockIcon,
|
||||
FilesIcon,
|
||||
LokiMark,
|
||||
NodesIcon,
|
||||
SettingsIcon,
|
||||
} from "./Icon";
|
||||
|
||||
export type View = "chat" | "history" | "files" | "tools" | "settings";
|
||||
|
||||
const NAV: { id: View; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "chat", label: "Demander", icon: <ChatIcon /> },
|
||||
{ id: "history", label: "Historique", icon: <ClockIcon /> },
|
||||
{ id: "files", label: "Fichiers", icon: <FilesIcon /> },
|
||||
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
|
||||
];
|
||||
|
||||
/** Barre latérale (264 px) : marque, navigation, état Ollama, compte. */
|
||||
export function Sidebar({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: View;
|
||||
onChange: (v: View) => void;
|
||||
}) {
|
||||
const { status, selectedModel, systemStats } = useStore();
|
||||
const gpu = systemStats?.gpu ?? null;
|
||||
|
||||
return (
|
||||
<aside className="flex w-[264px] flex-none flex-col border-r border-line bg-panel px-3.5 py-4">
|
||||
{/* Marque */}
|
||||
<div className="flex items-center gap-2.5 px-1.5 pb-4">
|
||||
<LokiMark size={26} />
|
||||
<span className="text-[16px] font-medium tracking-tight text-ink">Loki</span>
|
||||
<span className="ml-auto rounded-full border border-line px-2 py-0.5 text-[11px] text-muted-3">
|
||||
local
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-1.5 mb-3.5 h-px bg-line" />
|
||||
|
||||
<nav className="flex flex-col gap-[3px]">
|
||||
{NAV.map((it) => (
|
||||
<NavItem
|
||||
key={it.id}
|
||||
{...it}
|
||||
active={active === it.id}
|
||||
onClick={() => onChange(it.id)}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="font-pixel px-2.5 pb-2 pt-5 text-[10px] text-label">
|
||||
Configuration
|
||||
</div>
|
||||
<NavItem
|
||||
id="settings"
|
||||
label="Modèle & génération"
|
||||
icon={<SettingsIcon />}
|
||||
active={active === "settings"}
|
||||
onClick={() => onChange("settings")}
|
||||
/>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* État Ollama + GPU */}
|
||||
<div className="rounded-card border border-line p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
status?.connected ? "bg-accent" : "bg-warn"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-[12.5px] text-ink-3">
|
||||
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-2.5 truncate font-mono text-[12px] text-muted-3">
|
||||
{selectedModel || "aucun modèle"}
|
||||
</div>
|
||||
{gpu && (
|
||||
<>
|
||||
<div className="h-1 overflow-hidden rounded-sm bg-line-soft">
|
||||
<span
|
||||
className="block h-full bg-accent"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(gpu.vram_used_mb / Math.max(gpu.vram_total_mb, 1)) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-[11.5px] text-muted-3">
|
||||
VRAM {(gpu.vram_used_mb / 1000).toFixed(1)} /{" "}
|
||||
{(gpu.vram_total_mb / 1000).toFixed(1)} Go · GPU{" "}
|
||||
{Math.round(gpu.util_pct)} %
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compte local */}
|
||||
<div className="mt-3 flex items-center gap-2.5 rounded-card border border-line px-3 py-2.5">
|
||||
<span className="grid h-[30px] w-[30px] flex-none place-items-center rounded-full bg-line-soft text-[12px] text-ink-3">
|
||||
M
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13.5px] text-ink">Compte local</span>
|
||||
<span className="block truncate font-mono text-[11.5px] text-muted-3">
|
||||
{status?.host || "workspace"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
id: View;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2.5 rounded-card border px-2.5 py-2 text-left text-[14px] ${
|
||||
active
|
||||
? "border-accent-line bg-accent-ghost text-ink"
|
||||
: "border-transparent text-ink-3 hover:bg-accent-ghost hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { getVersion } from "../api/client";
|
||||
import { LokiMark } from "./Icon";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
/** Barre supérieure sombre : logo, fil d'Ariane, statut Ollama, sélecteur. */
|
||||
/** Barre supérieure : conversation courante, ressources, statut Ollama. */
|
||||
export function TopBar() {
|
||||
const status = useStore((s) => s.status);
|
||||
const stats = useStore((s) => s.systemStats);
|
||||
@@ -17,23 +16,17 @@ export function TopBar() {
|
||||
|
||||
return (
|
||||
<div className="flex h-[54px] flex-none items-center gap-3.5 border-b border-line bg-bar px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LokiMark />
|
||||
<div className="font-pixel text-[14px] text-ink">LOKI</div>
|
||||
<div
|
||||
className="text-[13px] leading-none text-on-dark-2"
|
||||
title={version ? `build ${version}` : undefined}
|
||||
>
|
||||
agent local
|
||||
{version && version !== "dev" && (
|
||||
<span className="ml-1.5 text-on-dark-3">· {version.slice(0, 7)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-[3px] bg-chrome-3" />
|
||||
|
||||
{/* La marque vit désormais dans la barre latérale : on ne garde ici que
|
||||
le contexte de la conversation en cours. */}
|
||||
<SessionMenu />
|
||||
{version && version !== "dev" && (
|
||||
<span
|
||||
className="text-[12px] text-muted-3"
|
||||
title={`build ${version}`}
|
||||
>
|
||||
{version.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
|
||||
Reference in new issue
Block a user