Retire la pile Python/React : Loki repart de la base Go d'AJEAN

L'ancien Loki (FastAPI + React + Ollama) est remplacé par le fork d'AJEAN.
Tout reste accessible dans l'historique (git show bb4fb1b:backend/app/agent.py).
This commit is contained in:
Loki committed 2026-08-14 21:30:13 +00:00
1 parent 9f1b0562cf
commit 2164b46319
87 files changed
-18730

No files matched your search

-19
View File
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>Loki - Agent local</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-4273
View File
File diff suppressed because it is too large. Load diff
-28
View File
@@ -1,28 +0,0 @@
{
"name": "loki-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-6
View File
@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#18181b"/>
<rect x="14" y="14" width="38" height="38" rx="7" fill="#ff5436"/>
<rect x="11" y="11" width="38" height="38" rx="7" fill="#ff5436" stroke="#ffffff" stroke-width="6"/>
<rect x="25" y="25" width="12" height="12" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 358 B

-73
View File
@@ -1,73 +0,0 @@
import { useEffect, useState } from "react";
import { Sidebar, type View } from "./components/Sidebar";
import { TopBar } from "./components/TopBar";
import { ChatPanel } from "./panels/ChatPanel";
import { PreviewPanel } from "./panels/PreviewPanel";
import { SettingsView } from "./panels/SettingsView";
import { HistoryView, ToolsView } from "./panels/ActivityViews";
import { useStore } from "./store/useStore";
export default function App() {
const [view, setView] = useState<View>("chat");
const { refreshPulse, refreshModels, refreshSessions } = useStore();
// Sondage frugal. Trois principes, contre l'app qui chauffait le CPU à vide :
// - UNE seule requête groupée (/api/system/pulse) au lieu de trois ;
// - RIEN quand l'onglet est caché (l'utilisateur ne regarde pas) ;
// - cadence adaptative : réactif pendant le travail, lent au repos.
// Le serveur, lui, ne tourne jamais en boucle : sans navigateur, zéro CPU.
useEffect(() => {
refreshPulse();
refreshModels();
refreshSessions();
let timer: number | undefined;
const tick = () => {
// Onglet caché : on ne sonde pas du tout, on repassera au retour.
if (document.hidden) return schedule();
refreshPulse().finally(schedule);
};
const schedule = () => {
if (document.hidden) {
timer = window.setTimeout(schedule, 30000);
return;
}
// Pendant un flux, les indicateurs (VRAM, GPU) doivent suivre.
const busy = useStore.getState().streaming;
timer = window.setTimeout(tick, busy ? 4000 : 20000);
};
schedule();
// Retour sur l'onglet : rafraîchit tout de suite plutôt que d'attendre.
const onVisible = () => {
if (!document.hidden) {
window.clearTimeout(timer);
tick();
}
};
document.addEventListener("visibilitychange", onVisible);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, [refreshPulse, refreshModels, refreshSessions]);
return (
<div className="flex h-full flex-col bg-base text-ink">
<TopBar />
<div className="flex min-h-0 flex-1">
<Sidebar active={view} onChange={setView} />
{view === "chat" && (
<>
<ChatPanel />
<PreviewPanel />
</>
)}
{view === "history" && <HistoryView onOpen={() => setView("chat")} />}
{view === "tools" && <ToolsView onSettings={() => setView("settings")} />}
{view === "settings" && <SettingsView />}
</div>
</div>
);
}
-795
View File
@@ -1,795 +0,0 @@
/** Petit client API typé pour le backend Loki. */
export interface OllamaStatus {
connected: boolean;
host: string;
version?: string;
error?: string;
default_model: string;
}
export interface OllamaModel {
name: string;
size_go: number;
parameter_size?: string;
quantization?: string;
family?: string;
}
export async function getStatus(): Promise<OllamaStatus> {
const res = await fetch("/api/status");
return res.json();
}
export interface GpuStats {
name: string;
util_pct: number;
vram_used_mb: number;
vram_total_mb: number;
}
export interface SystemStats {
cpu_pct: number;
ram_used_go: number;
ram_total_go: number;
ram_pct: number;
gpu: GpuStats | null;
}
export async function getSystemStats(): Promise<SystemStats> {
const res = await fetch("/api/system/stats");
return res.json();
}
/** Battement groupé : statut + ressources + modèles chargés en UNE requête. */
export interface Pulse {
status: OllamaStatus;
stats: SystemStats;
loaded: LoadedModel[];
}
export async function getPulse(): Promise<Pulse> {
const res = await fetch("/api/system/pulse");
if (!res.ok) throw new Error(`pulse ${res.status}`);
return res.json();
}
// ── Git du workspace ─────────────────────────────────────────────────────
export interface GitCommit {
hash: string;
full_hash: string;
subject: string;
author: string;
when: string;
files_changed: number;
}
const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function getGitLog(project?: string | null): Promise<GitCommit[]> {
try {
const res = await fetch(`/api/git/log?limit=40${projQuery(project)}`);
return (await res.json()).commits;
} catch {
return [];
}
}
export async function getGitDiff(
hash?: string,
project?: string | null
): Promise<string> {
const params = new URLSearchParams();
if (hash) params.set("hash", hash);
if (project) params.set("project", project);
const qs = params.toString();
const res = await fetch(`/api/git/diff${qs ? `?${qs}` : ""}`);
if (!res.ok) return "";
return (await res.json()).diff;
}
export async function revertCommit(
hash: string,
project?: string | null
): Promise<{ message: string }> {
const res = await fetch("/api/git/revert", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hash, project: project ?? null }),
});
if (!res.ok) throw await apiError(res, "revert impossible");
return res.json();
}
export interface HardwareInfo {
loki_gpus: {
index: number;
name: string;
vram_total_mb: number;
vram_used_mb: number;
util_pct: number;
}[];
gpu_override: { name: string; vram_total_mb: number } | null;
ollama: {
host: string;
connected: boolean;
version?: string;
error?: string;
running: {
name: string;
processor: string;
gpu_percent: number;
size_mb: number;
vram_mb: number;
}[];
};
ollama_is_local: boolean;
note: string;
}
export async function getHardware(): Promise<HardwareInfo> {
const res = await fetch("/api/system/hardware");
return res.json();
}
export async function getVersion(): Promise<string> {
try {
const res = await fetch("/api/version");
return (await res.json()).version ?? "?";
} catch {
return "?";
}
}
export interface LoadedModel {
name: string;
on_gpu: boolean;
gpu_percent: number;
}
async function apiError(res: Response, fallback: string): Promise<Error> {
try {
const payload = await res.json();
return new Error(payload?.detail ?? payload?.error ?? fallback);
} catch {
return new Error(fallback);
}
}
interface WarmStatus {
state: "idle" | "loading" | "loaded" | "error";
error?: string;
processor?: "gpu" | "cpu" | "mixte";
gpu_percent?: string;
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Lance le préchargement en arrière-plan puis suit son état. Chaque requête
* reste courte afin qu'un reverse proxy ne puisse plus interrompre le warm-up.
*/
export async function warmModel(
name: string,
keepAlive = "30m"
): Promise<WarmStatus> {
const res = await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
if (!res.ok) {
throw await apiError(res, `préchargement refusé (${res.status})`);
}
// Backoff progressif : réactif au début (modèle déjà chargé), espacé ensuite
// pour ne pas marteler l'API pendant un long chargement.
const deadline = Date.now() + 10 * 60 * 1000;
let delay = 1000;
while (Date.now() < deadline) {
const statusRes = await fetch(
`/api/models/warm/status?name=${encodeURIComponent(name)}`,
{ cache: "no-store" }
);
if (!statusRes.ok) {
throw await apiError(statusRes, `suivi du préchargement refusé (${statusRes.status})`);
}
const status = (await statusRes.json()) as WarmStatus;
if (status.state === "loaded") return status;
if (status.state === "error") {
throw new Error(status.error ?? "préchargement impossible");
}
await wait(delay);
delay = Math.min(delay * 1.5, 5000);
}
throw new Error("préchargement toujours en cours après 10 minutes");
}
export async function getLoadedModels(): Promise<LoadedModel[]> {
try {
const res = await fetch("/api/models/loaded");
return (await res.json()).loaded;
} catch {
return [];
}
}
export async function getModels(): Promise<{
models: OllamaModel[];
default: string;
}> {
const res = await fetch("/api/models");
return res.json();
}
export async function deleteModel(name: string): Promise<void> {
const res = await fetch("/api/models", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.detail ?? `suppression refusée (${res.status})`);
}
}
export interface Session {
id: string;
title: string;
model?: string;
project?: string | null;
created_at: number;
updated_at: number;
message_count?: number;
}
export interface ToolCall {
name: string;
args: Record<string, unknown>;
summary?: string;
status?: "ok" | "error" | "running" | "pending";
}
export interface MessageStats {
eval_count: number; // jetons générés
prompt_eval_count: number; // jetons du prompt
tokens_per_sec: number | null; // vitesse de génération
}
export interface Message {
id: string;
session_id: string;
role: "user" | "assistant";
content: string;
model?: string;
meta?: {
tools?: ToolCall[];
stats?: MessageStats;
thinking?: string;
plan?: string[];
engine?: string;
} | null;
created_at: number;
}
export interface AgentConfig {
system_prompt: string;
temperature: number;
top_p: number;
top_k: number;
max_tokens: number;
num_ctx: number;
num_gpu: number;
num_batch: number;
tools: Record<string, boolean>;
confirm_shell: boolean;
think: boolean;
code_model: string;
plan_mode: boolean;
self_review: boolean;
rag_enabled: boolean;
embed_model: string;
skills_enabled: boolean;
ponytail: boolean;
memory_mode: "off" | "ondemand" | "always";
keep_alive: string;
}
// ── Benchmark de modèles ─────────────────────────────────────────────────
export interface BenchDetail {
task: string;
score: number;
detail: string;
}
export interface BenchResult {
score: number;
details: BenchDetail[];
at: number;
}
export async function getBenchScores(): Promise<Record<string, BenchResult>> {
const res = await fetch("/api/bench");
return (await res.json()).scores;
}
/** Lance le benchmark d'un modèle en streamant la progression. */
export async function runBench(
model: string,
onProgress: (task: string, score: number | null, detail?: string) => void
): Promise<BenchResult | null> {
let res: Response;
try {
res = await fetch("/api/bench", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
} catch (err) {
const raw = err instanceof Error ? err.message : "connexion impossible";
throw new Error(
/network error|failed to fetch|load failed/i.test(raw)
? "connexion au benchmark interrompue par le réseau ou le reverse proxy"
: raw
);
}
if (!res.ok) {
throw await apiError(res, `benchmark refusé (${res.status})`);
}
if (!res.body) throw new Error("le serveur n'a pas renvoyé de progression");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let final: BenchResult | null = null;
const dispatch = (block: string) => {
let event = "message";
const dataLines: string[] = [];
for (const line of block.replace(/\r\n/g, "\n").split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
const payload = JSON.parse(dataLines.join("\n"));
if (event === "task_start") onProgress(payload.task, null);
else if (event === "task_done")
onProgress(payload.task, payload.score, payload.detail);
else if (event === "error")
throw new Error(payload.message ?? "le benchmark a échoué");
else if (event === "done")
final = { score: payload.score, details: payload.details, at: Date.now() / 1000 };
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} catch (err) {
const raw = err instanceof Error ? err.message : "connexion interrompue";
if (/network error|failed to fetch|load failed/i.test(raw)) {
throw new Error("flux du benchmark interrompu par le reverse proxy");
}
throw err;
}
if (!final) throw new Error("le benchmark s'est interrompu avant le résultat");
return final;
}
export async function runShell(
command: string
): Promise<{ command: string; exit_code: number; output: string }> {
const res = await fetch("/api/shell/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command }),
});
return res.json();
}
export async function getConfig(model?: string): Promise<{
config: AgentConfig;
available_tools: string[];
}> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config${query}`);
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(
patch: Partial<AgentConfig>,
model?: string
): Promise<AgentConfig> {
const query = model ? `?model=${encodeURIComponent(model)}` : "";
const res = await fetch(`/api/config${query}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
return (await res.json()).config;
}
export interface FileNode {
name: string;
path: string;
type: "dir" | "file";
size?: number;
children?: FileNode[];
}
export async function listFiles(project?: string | null): Promise<FileNode[]> {
const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree;
}
export async function fileContent(
path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return "";
return (await res.json()).content;
}
export async function deleteFile(
path: string,
project?: string | null
): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`);
}
}
export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export interface McpServer {
id: string;
label: string;
description: string;
url_param: boolean;
env_params: string[];
enabled: boolean;
params: Record<string, string>;
state: "inactive" | "connected" | "error";
error: string | null;
tools: number;
}
export async function listMcp(): Promise<McpServer[]> {
const res = await fetch("/api/mcp");
return (await res.json()).servers;
}
export async function updateMcp(
id: string,
enabled: boolean,
params: Record<string, string>
): Promise<McpServer[]> {
const res = await fetch(`/api/mcp/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, params }),
});
if (!res.ok) throw await apiError(res, "mise à jour MCP impossible");
return (await res.json()).servers;
}
export async function testMcp(
id: string
): Promise<{ ok: boolean; tools: string[]; error: string | null }> {
const res = await fetch(`/api/mcp/${id}/test`, { method: "POST" });
return res.json();
}
export async function listSessions(): Promise<Session[]> {
const res = await fetch("/api/sessions");
return (await res.json()).sessions;
}
export async function createSession(
model?: string,
project?: string | null
): Promise<Session> {
const res = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "Nouvelle session",
model,
project: project ?? null,
}),
});
return res.json();
}
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
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" });
}
export async function renameSession(id: string, title: string): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) {
throw await apiError(res, `renommage impossible (${res.status})`);
}
}
/** 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; mode?: string },
handlers: {
onToken: (t: string) => void;
onThinking: (t: string) => void;
onToolCall: (call: ToolCall) => void;
onToolResult: (call: ToolCall) => void;
onToolConfirm: (command: string) => void;
onStatus: (msg: string) => void;
onNotice: (msg: string) => void;
onPlan?: (steps: string[]) => void;
onPlanStep?: (index: number) => void;
onRevision?: (content: string) => void;
onDone: (full: string) => void;
onError: (msg: string) => void;
onAbort?: () => void;
},
signal?: AbortSignal
): Promise<void> {
let res: Response;
try {
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
const raw = err instanceof Error ? err.message : "serveur Loki injoignable";
handlers.onError(
/network error|failed to fetch/i.test(raw)
? "Serveur Loki injoignable. Vérifiez le reverse proxy et le conteneur."
: raw
);
return;
}
if (!res.ok) {
let message = `requête refusée (${res.status})`;
try {
const payload = await res.json();
message = payload.detail ?? payload.error ?? message;
} catch {
/* réponse non JSON */
}
handlers.onError(message);
return;
}
if (!res.body) {
handlers.onError("pas de flux de réponse");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
let failed = false;
const dispatch = (raw: string) => {
const block = raw.replace(/\r\n/g, "\n");
let event = "message";
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
try {
const payload = JSON.parse(dataLines.join("\n"));
if (event === "token") handlers.onToken(payload.content);
else if (event === "plan") handlers.onPlan?.(payload.steps);
else if (event === "plan_step") handlers.onPlanStep?.(payload.index);
else if (event === "revision") handlers.onRevision?.(payload.content);
else if (event === "thinking") handlers.onThinking(payload.content);
else if (event === "status") handlers.onStatus(payload.message);
else if (event === "notice") handlers.onNotice(payload.message);
else if (event === "tool_call")
handlers.onToolCall({ ...payload, status: "running" });
else if (event === "tool_result") handlers.onToolResult(payload);
else if (event === "tool_confirm") handlers.onToolConfirm(payload.command);
else if (event === "error") {
failed = true;
handlers.onError(payload.message);
} else if (event === "done") {
terminal = true;
if (payload.error) {
if (!failed) handlers.onError(payload.error);
failed = true;
} else if (!failed) {
handlers.onDone(payload.content);
}
}
} catch {
if (!failed) {
failed = true;
handlers.onError("réponse illisible reçue du serveur");
}
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} catch (err) {
if (!failed) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
failed = true;
const raw = err instanceof Error ? err.message : "connexion interrompue";
handlers.onError(
/network error|failed to fetch/i.test(raw)
? "Connexion interrompue pendant le chargement du modèle. Vérifiez OLLAMA_HOST et le délai du reverse proxy."
: raw
);
}
}
if (!terminal && !failed) {
handlers.onError("le serveur a fermé la réponse avant sa fin");
}
}
/** Télécharge un modèle en streamant la progression via SSE. */
export async function pullModel(
name: string,
onProgress: (status: string, percent: number) => void
): Promise<void> {
const res = await fetch("/api/models/pull", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.body) 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 lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const data = line.replace(/^data: /, "").trim();
if (!data || data === "[DONE]") continue;
try {
const chunk = JSON.parse(data);
const percent =
chunk.total && chunk.completed
? Math.round((chunk.completed / chunk.total) * 100)
: 0;
onProgress(chunk.status ?? "", percent);
} catch {
/* ligne partielle, ignorée */
}
}
}
}
-63
View File
@@ -1,63 +0,0 @@
import {
ChatIcon,
ClockIcon,
FilesIcon,
NodesIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "files" | "tools" | "settings";
const items: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", label: "Chat", icon: <ChatIcon /> },
{ id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "files", label: "Fichiers", icon: <FilesIcon /> },
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
];
const btn = (on: boolean) =>
`flex h-10 w-10 items-center justify-center border ${
on
? "border-white bg-accent text-white shadow-accent-soft"
: "border-chrome-3 text-on-dark-2 hover:text-ink"
}`;
/** Barre d'activité verticale (60px) sombre à gauche. */
export function ActivityBar({
active,
onChange,
}: {
active: View;
onChange: (v: View) => void;
}) {
return (
<div className="flex w-[60px] flex-none flex-col items-center gap-2 border-r border-line bg-bar py-3">
{items.map((it) => (
<button
key={it.id}
onClick={() => onChange(it.id)}
className={btn(active === it.id)}
title={it.label}
aria-label={it.label}
>
{it.icon}
</button>
))}
<div className="flex-1" />
<button
onClick={() => onChange("settings")}
className={btn(active === "settings")}
title="Configuration"
aria-label="Configuration"
>
<SettingsIcon />
</button>
<div className="font-pixel flex h-[34px] w-[34px] items-center justify-center border border-line bg-line-soft text-[11px] text-ink-3">
M
</div>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
import { useStore } from "../store/useStore";
import { downloadFile, type FileNode } from "../api/client";
import { DownloadIcon, TrashIcon } from "./Icon";
/** Arborescence du workspace : ouvrir dans l'aperçu, télécharger, supprimer. */
export function FileTree({
nodes,
depth = 0,
}: {
nodes: FileNode[];
depth?: number;
}) {
const { openPreview, previewPath, removeFile, currentProject } = useStore();
const confirmDelete = (n: FileNode) => {
const msg =
n.type === "dir"
? `Supprimer le dossier ${n.path} et tout son contenu ?`
: `Supprimer ${n.path} ?`;
if (window.confirm(msg)) void removeFile(n.path);
};
return (
<>
{nodes.map((n) => {
const active = n.type === "file" && n.path === previewPath;
return (
<div key={n.path}>
<div
onClick={() => n.type === "file" && openPreview(n.path)}
className={`group mb-[2px] flex items-center gap-2 rounded-card px-2 py-[5px] text-[13.5px] ${
n.type === "file"
? active
? "cursor-pointer border border-accent-line bg-accent-ghost text-ink"
: "cursor-pointer border border-transparent text-ink-3 hover:bg-accent-ghost"
: "text-muted-2"
}`}
style={{ paddingLeft: 8 + depth * 14 }}
>
{n.type === "dir" ? (
<span className="text-muted-3">▾</span>
) : (
<span
className={`h-1.5 w-1.5 rounded-sm ${
active ? "bg-accent" : "bg-muted-3"
}`}
/>
)}
<span className="min-w-0 flex-1 truncate">{n.name}</span>
{n.type === "file" && (
<button
onClick={(event) => {
event.stopPropagation();
downloadFile(n.path, currentProject());
}}
className="hidden flex-none text-muted-3 hover:text-accent group-hover:block"
title={`Télécharger ${n.name}`}
>
<DownloadIcon size={13} />
</button>
)}
<button
onClick={(event) => {
event.stopPropagation();
confirmDelete(n);
}}
className="hidden flex-none text-muted-3 hover:text-warn group-hover:block"
title={`Supprimer ${n.name}`}
>
<TrashIcon size={13} />
</button>
</div>
{n.children && <FileTree nodes={n.children} depth={depth + 1} />}
</div>
);
})}
</>
);
}
-126
View File
@@ -1,126 +0,0 @@
/** Icônes SVG (style line, stroke) reprises du visuel. */
type Props = { size?: number; className?: string };
const base = (size: number, className?: string) => ({
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.7,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
className,
});
export const ChatIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<path d="M4 6h16v10H10l-4 3v-3H4z" />
</svg>
);
export const ClockIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 1.8" />
</svg>
);
export const FilesIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<path d="M4 7h6l2 2h8v9H4z" />
</svg>
);
export const NodesIcon = ({ size = 19, className }: Props) => (
<svg {...base(size, className)}>
<circle cx="7" cy="7" r="2.4" />
<circle cx="17" cy="7" r="2.4" />
<circle cx="7" cy="17" r="2.4" />
<circle cx="17" cy="17" r="2.4" />
</svg>
);
export const SettingsIcon = ({ size = 18, className }: Props) => (
<svg {...base(size, className)}>
<line x1="4" y1="8" x2="20" y2="8" />
<line x1="4" y1="13" x2="20" y2="13" />
<line x1="4" y1="18" x2="20" y2="18" />
</svg>
);
export const ChevronDown = ({ size = 13, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2}>
<path d="m6 9 6 6 6-6" />
</svg>
);
export const PlusIcon = ({ size = 13, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.2}>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const RefreshIcon = ({ size = 15, className }: Props) => (
<svg {...base(size, className)}>
<path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" />
</svg>
);
export const SendIcon = ({ size = 15, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.2}>
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
);
export const ClipIcon = ({ size = 16, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.8}>
<path d="M21 11.5 12 20a5 5 0 0 1-7-7l8.5-8.5a3.5 3.5 0 0 1 5 5L10 16" />
</svg>
);
export const DownloadIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.9}>
<path d="M12 4v11m0 0 4-4m-4 4-4-4M5 20h14" />
</svg>
);
export const TrashIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.9}>
<path d="M4 7h16M10 7V5h4v2M9 7l.8 13h4.4L15 7M6.5 7l.7 13h9.6l.7-13" />
</svg>
);
export const CopyIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={1.7}>
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15V5a2 2 0 0 1 2-2h8" />
</svg>
);
export const CheckIcon = ({ size = 14, className }: Props) => (
<svg {...base(size, className)} strokeWidth={2.3}>
<path d="m5 12 4.5 4.5L19 7" />
</svg>
);
/** Logo Loki : carré teinté à filet accent + petit carré accent (maquette). */
export const LokiMark = ({ size = 30 }: { size?: number }) => (
<div
className="grid place-items-center border border-accent bg-accent-ghost"
style={{
width: size,
height: size,
borderRadius: Math.round(size * 0.27),
}}
>
<div
className="bg-accent"
style={{
width: size * 0.3,
height: size * 0.3,
borderRadius: 2,
}}
/>
</div>
);
-151
View File
@@ -1,151 +0,0 @@
import { useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CheckIcon, CopyIcon } from "./Icon";
function copyWithTextarea(value: string) {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("copy failed");
}
function CodeBlock({ lang, code }: { lang: string; code: string }) {
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const copy = async () => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code);
} else {
copyWithTextarea(code);
}
setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500);
} catch {
try {
copyWithTextarea(code);
setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500);
} catch {
setFailed(true);
setTimeout(() => setFailed(false), 1800);
}
}
};
return (
<div className="my-2 overflow-hidden border border-line bg-card-deep shadow-hard-sm" style={{ borderRadius: 7 }}>
<div className="flex items-center justify-between border-b border-chrome-2 px-3 py-1.5">
<span className="font-mono text-[10.5px] uppercase tracking-wide text-on-dark-2">
{lang || "code"}
</span>
<button
type="button"
onClick={copy}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-on-dark-2 transition-colors hover:text-ink"
>
{copied ? <CheckIcon size={13} /> : <CopyIcon size={13} />}
{failed ? "Erreur" : copied ? "Copié" : "Copier"}
</button>
</div>
<pre className="scr m-0 overflow-auto p-3 font-mono text-[12px] leading-relaxed text-on-dark">
<code>{code}</code>
</pre>
</div>
);
}
const components: Components = {
pre: ({ children }) => <>{children}</>,
code({ className, children }) {
const text = String(children).replace(/\n$/, "");
const match = /language-(\w+)/.exec(className || "");
if (match || text.includes("\n")) {
return <CodeBlock lang={match?.[1] ?? ""} code={text} />;
}
return (
<code className="rounded bg-sunken px-1 py-0.5 font-mono text-[12px] text-accent-1">
{children}
</code>
);
},
h1: ({ children }) => (
<h1 className="mb-2 mt-3 text-[17px] font-bold text-ink first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="mb-2 mt-3 text-[15px] font-bold text-ink first:mt-0">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="mb-1.5 mt-2.5 text-[13.5px] font-bold text-ink first:mt-0">
{children}
</h3>
),
p: ({ children }) => <p className="my-2 first:mt-0 last:mb-0">{children}</p>,
ul: ({ children }) => (
<ul className="my-2 list-disc space-y-1 pl-5 marker:text-muted-3">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="my-2 list-decimal space-y-1 pl-5 marker:text-muted-3">
{children}
</ol>
),
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{children}
</a>
),
strong: ({ children }) => (
<strong className="font-semibold text-ink">{children}</strong>
),
em: ({ children }) => <em className="italic">{children}</em>,
blockquote: ({ children }) => (
<blockquote className="my-2 border-l border-line-strong pl-3 text-muted">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-line-soft" />,
table: ({ children }) => (
<div className="my-2 overflow-x-auto">
<table className="w-full border-collapse text-[12.5px]">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border border-line-strong bg-card-deep px-2.5 py-1.5 text-left font-semibold text-ink-2">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-line-soft px-2.5 py-1.5">{children}</td>
),
};
export function MessageContent({ text }: { text: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{text}
</ReactMarkdown>
);
}
-124
View File
@@ -1,124 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown } from "./Icon";
/** Sélecteur de modèle Ollama (chip orange, pastille = état de chargement).
variant "composer" : taille alignée sur les chips du composer, menu vers
le haut. */
export function ModelSelector({
variant = "top",
}: {
variant?: "top" | "composer";
}) {
const {
models,
selectedModel,
setSelectedModel,
loadedModels,
warmingModel,
warmError,
} = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const loadedInfo = (name: string) =>
loadedModels.find((m) => m.name === name);
const selectedLoaded = loadedInfo(selectedModel);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={
variant === "composer"
? "flex h-8 min-w-0 max-w-[220px] items-center gap-1.5 border border-line bg-accent px-2.5 text-[13px] text-white"
: "flex h-[34px] min-w-0 max-w-[260px] items-center gap-2 border border-white bg-accent px-3 text-white shadow-accent-soft"
}
title={selectedModel || undefined}
style={{ borderRadius: 7 }}
>
<span
className={`h-2.5 w-2.5 border border-line ${
warmingModel === selectedModel
? "bg-accent animate-pulse"
: selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
warmError
? warmError
: warmingModel === selectedModel
? "préchargement en cours…"
: selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
: "à charger"
}
/>
<span className="min-w-0 truncate text-[14px] leading-none">{selectedModel || "—"}</span>
<ChevronDown size={12} className="flex-none" />
</button>
{open && (
<div
className={`absolute z-20 w-64 border border-line bg-card p-1 shadow-hard ${
variant === "composer"
? "bottom-[calc(100%+6px)] left-0"
: "right-0 top-11"
}`}
>
{models.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-2">
Aucun modèle installé
</div>
)}
{models.map((m) => (
<button
key={m.name}
onClick={() => {
setSelectedModel(m.name);
setOpen(false);
}}
className={`flex w-full items-center gap-2 px-2 py-2 text-left hover:bg-base ${
m.name === selectedModel ? "bg-base" : ""
}`}
>
<span
className={`h-2 w-2 border border-line ${
m.name === selectedModel ? "bg-accent" : "bg-card"
}`}
/>
<span className="min-w-0 flex-1 truncate text-xs text-ink-2" title={m.name}>
{m.name}
</span>
{loadedInfo(m.name) && (
<span
className={`h-1.5 w-1.5 border border-line ${
loadedInfo(m.name)!.on_gpu ? "bg-ok" : "bg-warn"
}`}
title={loadedInfo(m.name)!.on_gpu ? "chargé GPU" : "chargé CPU"}
/>
)}
{m.size_go > 0 && (
<span className="text-[10px] text-muted-2">{m.size_go} Go</span>
)}
</button>
))}
</div>
)}
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { createProject } from "../api/client";
/** Sélecteur de projet du composer : 📁 <projet> + menu (liste, création). */
export function ProjectChip() {
const { projects, refreshProjects, currentProject, setProject } = useStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const active = currentProject();
useEffect(() => {
if (open) void refreshProjects();
}, [open, refreshProjects]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setError(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const choose = async (name: string | null) => {
setOpen(false);
await setProject(name);
};
const create = async () => {
const name = draft.trim();
if (!name) return;
try {
await createProject(name);
setCreating(false);
setDraft("");
await choose(name);
} catch (err) {
setError(err instanceof Error ? err.message : "création impossible");
}
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex h-8 items-center gap-1.5 border border-line bg-base px-2.5 text-[12px] text-ink-2"
title="Projet de travail de cette session"
>
📁 <span className="max-w-[140px] truncate">{active ?? "workspace"}</span>
</button>
{open && (
<div
className="absolute bottom-[calc(100%+6px)] left-0 z-30 w-[240px] border border-line bg-card shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={() => choose(null)}
className={`block w-full px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === null ? "bg-base font-bold" : ""
}`}
>
workspace (racine)
</button>
{projects.map((p) => (
<button
key={p.name}
onClick={() => choose(p.name)}
className={`block w-full border-t border-line-soft px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === p.name ? "bg-base font-bold" : ""
}`}
>
📁 {p.name}
<span className="ml-1.5 text-[11px] text-muted-2">
{p.files} fichier{p.files > 1 ? "s" : ""}
</span>
</button>
))}
<div className="border-t border-line-soft p-2">
{creating ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void create();
if (e.key === "Escape") {
setCreating(false);
setError(null);
}
}}
placeholder="nom-du-projet"
className="w-full border border-line bg-base px-2 py-1 text-[12px] text-ink outline-none"
/>
) : (
<button
onClick={() => setCreating(true)}
className="w-full text-left text-[13px] font-bold text-accent"
>
+ Nouveau projet
</button>
)}
{error && <div className="mt-1 text-[11px] text-warn">{error}</div>}
</div>
</div>
)}
</div>
);
}
-174
View File
@@ -1,174 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown, PlusIcon, TrashIcon } from "./Icon";
import { relTime } from "../lib/time";
/** Sélecteur de session de la barre supérieure : titre courant + menu déroulant
* (nouvelle session, liste, renommage inline, suppression). */
export function SessionMenu() {
const {
sessions,
currentSessionId,
streamingSessionId,
newSession,
openSession,
removeSession,
renameSession,
} = useStore();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const current = sessions.find((s) => s.id === currentSessionId);
// Fermeture au clic extérieur + Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setEditingId(null);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
setEditingId(null);
}
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const commitRename = async (id: string) => {
const title = draft.trim();
setEditingId(null);
if (title) await renameSession(id, title);
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex cursor-pointer items-center gap-2 text-[13px] text-on-dark hover:text-ink"
title="Sessions"
>
<span className="max-w-[220px] truncate">
{current?.title ?? "Nouvelle session"}
</span>
<ChevronDown className="text-on-dark-3" />
</button>
{open && (
<div
className="absolute left-0 top-[calc(100%+9px)] z-30 w-[300px] border border-line bg-chrome-2 shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={async () => {
setOpen(false);
await newSession();
}}
className="flex w-full cursor-pointer items-center gap-2 border-b border-chrome-3 px-3 py-2.5 text-[13px] font-bold text-accent hover:bg-chrome-3"
>
<PlusIcon />
Nouvelle session
</button>
<div className="scr max-h-[320px] overflow-auto">
{sessions.length === 0 && (
<div className="px-3 py-4 text-center text-[12px] text-on-dark-3">
Aucune session enregistrée.
</div>
)}
{sessions.map((s) => {
const active = s.id === currentSessionId;
const working = s.id === streamingSessionId;
return (
<div
key={s.id}
onClick={() => {
if (editingId === s.id) return;
setOpen(false);
void openSession(s.id);
}}
className={`group cursor-pointer border-b border-chrome-3 px-3 py-2 last:border-b-0 ${
active ? "bg-chrome-3" : "hover:bg-chrome-3"
}`}
>
<div className="flex items-center gap-1.5">
{editingId === s.id ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(s.id);
if (e.key === "Escape") {
e.stopPropagation();
setEditingId(null);
}
}}
onBlur={() => setEditingId(null)}
className="min-w-0 flex-1 border border-line bg-card-deep px-1.5 py-0.5 text-[13px] text-ink outline-none"
/>
) : (
<span
className={`min-w-0 flex-1 truncate text-[13px] ${
active ? "text-ink" : "text-ink-3"
}`}
>
{s.title}
</span>
)}
{working && (
<span
className="flex-none border border-line bg-accent px-1 py-0.5 text-[9px] leading-none text-white"
title="Session en cours de travail"
>
EN COURS
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
setEditingId(s.id);
setDraft(s.title);
}}
className="hidden flex-none text-[11px] text-on-dark-3 hover:text-ink group-hover:block"
title="Renommer la session"
>
✎
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (window.confirm("Supprimer cette session ?")) {
void removeSession(s.id);
}
}}
className="hidden flex-none text-on-dark-3 hover:text-accent group-hover:block"
title="Supprimer la session"
>
<TrashIcon size={12} />
</button>
</div>
<div className="mt-0.5 text-[11px] text-on-dark-3">
{relTime(s.updated_at)} · {s.message_count ?? 0} message
{(s.message_count ?? 0) > 1 ? "s" : ""}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
-211
View File
@@ -1,211 +0,0 @@
import { useStore } from "../store/useStore";
import {
ChatIcon,
ClockIcon,
LokiMark,
NodesIcon,
PlusIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "tools" | "settings";
// Les fichiers ne sont plus une vue : ils vivent dans l'onglet « Fichiers »
// du panneau droit, à côté de Code.
const NAV: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", label: "Discussion", icon: <ChatIcon /> },
{ id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "tools", label: "Outils", icon: <NodesIcon /> },
];
/** Nombre de discussions récentes listées sous « Discussion ». */
const RECENT = 5;
/** 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,
sessions,
currentSessionId,
streamingSessionId,
newSession,
openSession,
} = useStore();
const gpu = systemStats?.gpu ?? null;
const recent = sessions.slice(0, RECENT);
const startChat = () => {
void newSession();
onChange("chat");
};
const pick = (id: string) => {
void openSession(id);
onChange("chat");
};
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]">
<NavItem
id="chat"
label={NAV[0].label}
icon={NAV[0].icon}
active={active === "chat"}
onClick={() => onChange("chat")}
/>
{/* Discussions récentes, directement sous « Discussion ». */}
<div className="mb-1 ml-2 flex flex-col gap-px border-l border-line pl-2">
<button
onClick={startChat}
className="flex items-center gap-1.5 rounded-card px-2 py-1.5 text-left text-[13px] text-accent hover:bg-accent-ghost"
>
<PlusIcon />
Nouvelle discussion
</button>
{recent.map((s) => (
<button
key={s.id}
onClick={() => pick(s.id)}
title={s.title}
className={`flex items-center gap-1.5 truncate rounded-card px-2 py-1.5 text-left text-[13px] ${
s.id === currentSessionId
? "bg-accent-ghost text-ink"
: "text-muted hover:bg-accent-ghost hover:text-ink"
}`}
>
<span className="min-w-0 flex-1 truncate">{s.title}</span>
{s.id === streamingSessionId && (
<span className="h-1.5 w-1.5 flex-none rounded-full bg-accent" />
)}
</button>
))}
{recent.length === 0 && (
<span className="px-2 py-1.5 text-[12.5px] text-muted-3">
Aucune discussion
</span>
)}
</div>
{NAV.slice(1).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>
);
}
-114
View File
@@ -1,114 +0,0 @@
import type { ToolCall } from "../api/client";
/** Carte d'appel d'outil rendue dans le fil (fidèle à la maquette). */
export function ToolCard({ call }: { call: ToolCall }) {
const running = call.status === "running";
const error = call.status === "error";
const pending = call.status === "pending";
// Argument principal affiché entre parenthèses selon l'outil.
const mainArg =
(call.args?.path as string) ??
(call.args?.query as string) ??
(call.args?.pattern as string) ??
(call.args?.command as string) ??
(call.args?.instruction as string);
const argPreview =
typeof mainArg === "string"
? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")`
: Object.keys(call.args ?? {}).length
? "(…)"
: "()";
const write = call.name === "write_file" || call.name === "edit_file";
return (
<div className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
<div className="flex items-center gap-2.5 px-3 py-2.5">
<span
className={`flex h-[26px] w-[26px] items-center justify-center border border-line ${
write ? "bg-accent text-white" : "bg-card-deep text-ink"
}`}
>
<ToolGlyph name={call.name} />
</span>
<span className="text-[14px] text-ink">{call.name}</span>
<span className="text-[13px] text-muted-3">{argPreview}</span>
<div className="flex-1" />
{running ? (
<span className="flex items-center gap-1.5 text-[13px] text-muted-2">
<span className="h-2.5 w-2.5 animate-spin rounded-full border border-muted-3 border-t-accent" />
en cours
</span>
) : pending ? (
<span className="text-[13px] text-accent">⏸ à valider</span>
) : (
<span
className={`flex items-center gap-1.5 text-[13px] ${
error ? "text-warn" : "text-ok"
}`}
>
{error ? "✕ échec" : "✓ terminé"}
</span>
)}
</div>
{call.summary && !running && (
<div className="border-t border-line-soft px-3 py-2 text-[13px] text-muted-2">
→ {call.summary}
</div>
)}
</div>
);
}
function ToolGlyph({ name }: { name: string }) {
const common = {
width: 13,
height: 13,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.9,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
if (name === "write_file" || name === "edit_file")
return (
<svg {...common}>
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
</svg>
);
if (name === "list_dir")
return (
<svg {...common}>
<path d="M4 7h6l2 2h8v9H4z" />
</svg>
);
if (name === "code_task")
return (
<svg {...common}>
<path d="m8 6-5 6 5 6M16 6l5 6-5 6" />
</svg>
);
if (name === "web_search" || name === "grep_search")
return (
<svg {...common}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
if (name === "run_shell")
return (
<svg {...common}>
<path d="m6 9 3 3-3 3M13 15h5" />
<rect x="2" y="4" width="20" height="16" rx="2" />
</svg>
);
// read_file (défaut)
return (
<svg {...common}>
<path d="M7 4h7l4 4v12H7z" />
<path d="M14 4v4h4" />
</svg>
);
}
-69
View File
@@ -1,69 +0,0 @@
import { useEffect, useState } from "react";
import { useStore } from "../store/useStore";
import { getVersion } from "../api/client";
import { SessionMenu } from "./SessionMenu";
/** Barre supérieure : conversation courante, ressources, statut Ollama. */
export function TopBar() {
const status = useStore((s) => s.status);
const stats = useStore((s) => s.systemStats);
const connected = status?.connected ?? false;
const [version, setVersion] = useState("");
useEffect(() => {
getVersion().then(setVersion);
}, []);
return (
<div className="flex h-[54px] flex-none items-center gap-3.5 border-b border-line bg-bar px-4">
{/* 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" />
{/* Stats système temps réel : CPU, RAM, GPU/VRAM */}
{stats && (
<div className="flex h-8 items-center gap-2.5 border border-chrome-3 bg-chrome-2 px-[11px] text-[13px] text-on-dark">
<span>CPU {stats.cpu_pct.toFixed(0)}%</span>
<span className="text-on-dark-3">·</span>
<span>RAM {stats.ram_pct.toFixed(0)}%</span>
{stats.gpu && (
<>
<span className="text-on-dark-3">·</span>
<span>GPU {stats.gpu.util_pct.toFixed(0)}%</span>
<span className="text-on-dark-3">
VRAM {(stats.gpu.vram_used_mb / 1024).toFixed(1)}/
{(stats.gpu.vram_total_mb / 1024).toFixed(1)}G
</span>
</>
)}
</div>
)}
{/* Statut Ollama */}
<div
className="flex h-8 items-center gap-2 border border-chrome-3 bg-chrome-2 px-[11px]"
title={connected ? status?.host : status?.error}
>
<span
className={`h-[11px] w-[11px] border border-line ${
connected ? "bg-ok" : "bg-warn"
}`}
/>
<span className="text-[13px] text-on-dark">Ollama</span>
<span className="text-[13px] text-on-dark-3">
{connected ? status?.host.replace(/^https?:\/\//, "") : "déconnecté"}
</span>
</div>
</div>
);
}
-56
View File
@@ -1,56 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: #f7f8fa;
color: #1c2536;
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Titres : léger resserrement, comme dans la maquette Nocturne. */
h1,
h2,
h3 {
letter-spacing: -0.025em;
}
/* Ex-police pixel : « kicker » de la maquette (petites capitales espacées). */
.font-pixel {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* Barres de défilement discrètes (règles .scr de la maquette) */
.scr::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scr::-webkit-scrollbar-thumb {
background: #c3c9d2;
border-radius: 6px;
border: 3px solid transparent;
background-clip: content-box;
}
.scr::-webkit-scrollbar-track {
background: transparent;
}
/* Focus visible accentué, sélection teintée — repris de la maquette. */
:focus-visible {
outline: 2px solid #2f4a7a;
outline-offset: 2px;
}
::selection {
background: rgba(47, 74, 122, 0.32);
}
-8
View File
@@ -1,8 +0,0 @@
/** Date relative courte en français (« il y a 5 min »). */
export 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`;
}
-10
View File
@@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
-94
View File
@@ -1,94 +0,0 @@
import { useEffect } from "react";
import { useStore } from "../store/useStore";
export function HistoryView({ onOpen }: { onOpen: () => void }) {
const { sessions, refreshSessions, openSession, removeSession } = useStore();
useEffect(() => {
refreshSessions();
}, [refreshSessions]);
return (
<Page title="Historique" subtitle="Toutes les conversations enregistrées localement.">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
{sessions.map((session) => (
<div key={session.id} className="border border-line bg-card p-4 shadow-hard">
<div className="truncate text-[15px] font-semibold text-ink">{session.title}</div>
<div className="mt-1 text-[12px] text-muted-2">
{session.message_count ?? 0} message(s) · {new Date(session.updated_at * 1000).toLocaleString("fr-FR")}
</div>
<div className="mt-4 flex gap-2">
<button
onClick={async () => {
await openSession(session.id);
onOpen();
}}
className="border border-line bg-accent px-3 py-1.5 text-[12px] font-bold text-white"
>
Ouvrir
</button>
<button
onClick={() => removeSession(session.id)}
className="border border-line bg-base px-3 py-1.5 text-[12px] text-warn"
>
Supprimer
</button>
</div>
</div>
))}
{sessions.length === 0 && <Empty text="Aucune conversation enregistrée." />}
</div>
</Page>
);
}
export function ToolsView({ onSettings }: { onSettings: () => void }) {
const { config, availableTools, refreshConfig, selectedModel } = useStore();
useEffect(() => {
refreshConfig();
}, [refreshConfig, selectedModel]);
return (
<Page title="Outils" subtitle={`Capacités proposées à ${selectedModel || "l’agent"}.`}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{availableTools.map((name) => {
const enabled = config?.tools[name] ?? false;
return (
<div key={name} className="border border-line bg-card p-4 shadow-hard">
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-[14px] text-ink">{name}</span>
<span className={`border border-line px-2 py-1 text-[10px] ${enabled ? "bg-ok text-white" : "bg-base text-muted-2"}`}>
{enabled ? "ACTIF" : "INACTIF"}
</span>
</div>
</div>
);
})}
</div>
<button
onClick={onSettings}
className="mt-5 border border-line bg-accent px-4 py-2 text-[13px] font-bold text-white shadow-hard"
>
Configurer les outils
</button>
</Page>
);
}
function Page({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
return (
<div className="scr min-w-0 flex-1 overflow-auto bg-base p-7">
<div className="mx-auto max-w-[1100px]">
<h1 className="m-0 text-xl font-bold text-ink">{title}</h1>
<p className="mb-6 mt-1 text-[13px] text-muted-2">{subtitle}</p>
{children}
</div>
</div>
);
}
function Empty({ text }: { text: string }) {
return <div className="border border-line bg-card p-8 text-center text-muted-2">{text}</div>;
}
-549
View File
@@ -1,549 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown, LokiMark, SendIcon } from "../components/Icon";
import { ToolCard } from "../components/ToolCard";
import { MessageContent } from "../components/MessageContent";
import { ProjectChip } from "../components/ProjectChip";
import { ModelSelector } from "../components/ModelSelector";
import type { Message, ToolCall } from "../api/client";
/** Panneau central : barre de contexte, fil de conversation, composer. */
export function ChatPanel() {
const {
selectedModel,
messages,
streaming,
streamingSessionId,
streamContent,
streamThinking,
streamStatus,
streamNotice,
streamTools,
streamPlan,
streamPlanDone,
sendMessage,
currentSessionId,
config,
pendingShell,
approveShell,
rejectShell,
sessions,
stopStreaming,
} = useStore();
const activeTools = config
? Object.values(config.tools).filter(Boolean).length
: 0;
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, streamThinking, streamTools, streamPlan, streamPlanDone]);
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 showingStreaming = streaming && currentSessionId === streamingSessionId;
const workingSession = sessions.find((s) => s.id === streamingSessionId);
const empty = messages.length === 0 && !showingStreaming;
return (
<div className="flex min-w-0 flex-1 flex-col bg-base">
{/* Barre de contexte */}
<div className="flex h-[46px] flex-none items-center gap-2 border-b border-line bg-panel px-4">
<Chip>⚑ Invite système</Chip>
<Chip>
<span className="h-2 w-2 border border-line bg-ok" />
{activeTools} outil{activeTools > 1 ? "s" : ""}
</Chip>
<Chip>
Temp{" "}
<b className="text-accent">{config ? config.temperature.toFixed(1) : "—"}</b>
</Chip>
<div className="flex-1" />
<span className="text-[13px] text-muted-3">
{currentSessionId
? `${messages.length} message${messages.length > 1 ? "s" : ""}`
: "aucune session"}
</span>
</div>
{/* Messages */}
<div ref={scrollRef} className="scr flex-1 overflow-auto px-7 py-6">
{empty ? (
<Welcome
sessionCount={sessions.length}
onPick={(text) => setDraft(text)}
/>
) : (
<div className="mx-auto flex max-w-[680px] flex-col gap-5">
{messages.map((m) => (
<Bubble key={m.id} msg={m} />
))}
{showingStreaming && (
<Bubble
msg={{
id: "stream",
session_id: "",
role: "assistant",
content: streamContent,
model: selectedModel,
meta: { tools: streamTools, plan: streamPlan },
created_at: Date.now() / 1000,
}}
pending
pendingStatus={streamStatus}
notice={streamNotice}
thinking={streamThinking}
planDone={streamPlanDone}
/>
)}
{/* La validation shell persiste APRÈS la fin du flux : l'agent
termine son tour en attendant l'utilisateur, donc le streaming
s'arrête — la carte doit rester tant qu'on n'a pas tranché. */}
{pendingShell && (
<ShellConfirm
command={pendingShell}
onApprove={approveShell}
onReject={rejectShell}
/>
)}
</div>
)}
</div>
{/* Composer */}
<div className="flex-none border-t border-line bg-panel px-7 pb-[18px] pt-3.5">
<div className="mx-auto max-w-[680px]">
<div className="border border-line bg-card p-3 shadow-hard" style={{ borderRadius: 8 }}>
<textarea
rows={1}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Envoyer un message à l'agent…"
className="min-h-[40px] w-full resize-none bg-transparent text-[14px] leading-relaxed text-ink outline-none placeholder:text-muted-3"
/>
<div className="mt-1.5 flex items-center gap-2">
<ModeSelector />
<ProjectChip />
<ModelSelector variant="composer" />
{streaming && !showingStreaming ? (
<span className="min-w-0 flex-1 truncate text-[13px] text-accent">
Travail en cours : {workingSession?.title ?? "session ouverte"}
</span>
) : (
<>
<div className="flex-1" />
<span className="text-[13px] text-muted-3">⏎ envoyer · ⇧⏎ ligne</span>
</>
)}
<button
onClick={streaming ? stopStreaming : submit}
disabled={!streaming && !draft.trim()}
className={`flex h-[38px] items-center gap-1.5 border border-line px-4 text-[14px] text-white shadow-hard-accent disabled:opacity-40 ${
streaming ? "bg-warn" : "bg-accent"
}`}
style={{ borderRadius: 7 }}
>
{streaming ? "ARRÊTER" : "ENVOYER"}
{!streaming && <SendIcon />}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
/** Pistes proposées sur l'écran d'accueil (maquette « Loki App »). */
const STARTERS = [
["📄", "Résumer un dossier", "Résume le contenu du dossier "],
["🐞", "Corriger un bug", "Corrige le bug suivant : "],
["✏️", "Refactorer un fichier", "Refactore le fichier "],
["⌨️", "Écrire un script", "Écris un script qui "],
["🔍", "Chercher dans le code", "Cherche dans le code "],
["📊", "Analyser un CSV", "Analyse le fichier CSV "],
["🌿", "Préparer un commit", "Prépare un commit pour "],
["📥", "Générer un rapport", "Génère un rapport sur "],
] as const;
/** Écran d'accueil : accroche, pistes cliquables, renvoi vers l'historique. */
function Welcome({
sessionCount,
onPick,
}: {
sessionCount: number;
onPick: (text: string) => void;
}) {
return (
<div className="flex h-full flex-col items-center justify-center px-7 py-10 text-center">
<LokiMark size={44} />
<h1 className="mt-6 text-[34px] font-medium text-ink">
Bienvenue dans Loki
</h1>
<p className="mt-3 max-w-[52ch] text-[15.5px] leading-relaxed text-muted">
Choisissez une piste ci-dessous, ou écrivez directement votre demande.
Tout s'exécute sur cette machine, aucune donnée ne sort.
</p>
<div className="mt-8 flex max-w-[760px] flex-wrap justify-center gap-2.5">
{STARTERS.map(([icon, label, prefill]) => (
<button
key={label}
onClick={() => onPick(prefill)}
className="flex items-center gap-2 whitespace-nowrap rounded-full border border-line px-3.5 py-2 text-[13.5px] text-ink-2 hover:border-accent hover:bg-accent-ghost"
>
<span aria-hidden>{icon}</span>
{label}
</button>
))}
</div>
{sessionCount > 0 && (
<div className="mt-9 flex items-center gap-3 text-[12.5px] text-muted-3">
<span className="h-px w-11 bg-line" />
{sessionCount} conversation{sessionCount > 1 ? "s" : ""} enregistrée
{sessionCount > 1 ? "s" : ""} localement
<span className="h-px w-11 bg-line" />
</div>
)}
</div>
);
}
/** Plan d'exécution affiché avant le travail de l'agent. */
const MODES = [
{ id: "plan", label: "Plan", icon: "🔍", desc: "Lecture seule : analyse et propose, sans rien modifier" },
{ id: "build", label: "Build", icon: "🔨", desc: "Normal : écrit les fichiers, confirme les commandes shell" },
{ id: "yolo", label: "Yolo", icon: "⚡", desc: "Auto : approuve tout, y compris le shell" },
] as const;
/** Sélecteur de mode d'exécution (Plan / Build / Yolo) dans le composer. */
function ModeSelector() {
const { mode, setMode } = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
const current = MODES.find((m) => m.id === mode) ?? MODES[1];
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={`flex h-8 items-center gap-1.5 border border-line px-2.5 text-[13px] ${
mode === "plan"
? "bg-info text-white"
: mode === "yolo"
? "bg-accent text-white"
: "bg-card text-ink-2"
}`}
title={current.desc}
>
<span>{current.icon}</span>
<span>{current.label}</span>
<ChevronDown size={11} />
</button>
{open && (
<div className="absolute bottom-10 left-0 z-20 w-64 border border-line bg-card p-1 shadow-hard">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => {
setMode(m.id);
setOpen(false);
}}
className={`flex w-full flex-col items-start gap-0.5 px-2 py-2 text-left hover:bg-base ${
m.id === mode ? "bg-base" : ""
}`}
>
<span className="text-[13px] text-ink">
{m.icon} {m.label}
</span>
<span className="text-[11px] leading-tight text-muted-2">{m.desc}</span>
</button>
))}
</div>
)}
</div>
);
}
function PlanCard({
steps,
done,
live,
}: {
steps: string[];
done?: number[];
live?: boolean;
}) {
const doneSet = new Set(done ?? []);
// Étape active = la première non validée, uniquement pendant le stream.
const activeIdx = live
? steps.findIndex((_, i) => !doneSet.has(i))
: -1;
const doneCount = doneSet.size;
return (
<div
className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm"
style={{ borderRadius: 7 }}
>
<div className="flex items-center gap-2 border-b border-line-soft px-3 py-2">
<span className="font-pixel text-[9px] text-accent">PLAN</span>
<span className="text-[12px] text-muted-2">
{doneCount > 0
? `${doneCount}/${steps.length} validée${doneCount > 1 ? "s" : ""}`
: `${steps.length} étape${steps.length > 1 ? "s" : ""}`}
</span>
</div>
<ol className="m-0 list-none px-3 py-2">
{steps.map((s, i) => {
const isDone = doneSet.has(i);
const isActive = i === activeIdx;
return (
<li
key={i}
className={`flex gap-2 py-[3px] text-[13px] ${
isDone ? "text-muted-2" : "text-ink-2"
}`}
>
<span
className={`flex h-[18px] w-[18px] flex-none items-center justify-center border text-[11px] ${
isDone
? "border-ok bg-ok text-white"
: isActive
? "border-accent bg-base text-accent"
: "border-line bg-base text-ink"
}`}
>
{isDone ? "✓" : isActive ? "…" : i + 1}
</span>
<span className={`min-w-0 ${isDone ? "line-through" : ""}`}>
{s}
</span>
</li>
);
})}
</ol>
</div>
);
}
function Bubble({
msg,
pending,
pendingStatus,
notice,
thinking,
planDone,
}: {
msg: Message;
pending?: boolean;
pendingStatus?: string;
notice?: string | null;
thinking?: string;
planDone?: number[];
}) {
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="font-pixel flex h-[34px] w-[34px] flex-none items-center justify-center border border-line bg-card-deep text-[11px] text-ink">
M
</div>
<div className="flex-1">
<div className="mb-1.5 flex items-baseline gap-2">
<span className="text-[14px] text-ink">VOUS</span>
<span className="text-[13px] text-muted-3">{time}</span>
</div>
<div className="border border-line bg-card px-[14px] py-3 text-[14px] leading-snug text-ink shadow-hard-sm whitespace-pre-wrap" style={{ borderRadius: 7 }}>
{msg.content}
</div>
</div>
</div>
);
}
return (
<div className="flex gap-3">
<div className="grid h-[34px] w-[34px] flex-none place-items-center rounded-[9px] border border-accent bg-accent-ghost">
<div className="bg-accent" style={{ width: 10, height: 10, borderRadius: 2 }} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline gap-2">
<span className="text-[14px] text-ink">LOKI</span>
<span className="text-[13px] text-muted-3">
{msg.model ? `${msg.model} · ` : ""}
{time}
</span>
</div>
<ReasoningPanel
text={thinking ?? msg.meta?.thinking ?? ""}
live={!!pending}
/>
{(msg.meta?.plan?.length ?? 0) > 0 && (
<PlanCard steps={msg.meta!.plan!} done={planDone} live={!!pending} />
)}
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} />
))}
{notice && (
<div className="mb-2 border border-line bg-card px-3 py-2 text-[13px] text-warn">
{notice}
</div>
)}
{(msg.content || pending) && (
<div className="text-[14px] leading-[1.5] text-ink-2">
{msg.content ? (
<MessageContent text={msg.content} />
) : (
<span className="text-muted-2">{pendingStatus || "Génération…"}</span>
)}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
</div>
)}
{!pending && msg.meta?.stats && (
<div className="mt-2 flex items-center gap-2.5 text-[12px] text-muted-3">
{msg.meta.stats.tokens_per_sec != null && (
<span className="text-accent-2">
{msg.meta.stats.tokens_per_sec} tok/s
</span>
)}
<span>{msg.meta.stats.eval_count} jetons</span>
{msg.meta.stats.prompt_eval_count > 0 && (
<span>· {msg.meta.stats.prompt_eval_count} en entrée</span>
)}
</div>
)}
</div>
</div>
);
}
/** Panneau « Raisonnement » : repliable et redimensionnable (poignée en bas). */
function ReasoningPanel({ text, live }: { text: string; live: boolean }) {
const [open, setOpen] = useState(live);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (live) setOpen(true);
}, [live]);
useEffect(() => {
if (open && live) {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
}
}, [text, open, live]);
if (!text) return null;
return (
<div className="mb-[11px] overflow-hidden border border-line bg-card shadow-hard-sm" style={{ borderRadius: 7 }}>
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 px-3 py-2 text-left"
>
<ChevronDown
size={12}
className={`text-ink-2 transition-transform ${open ? "" : "-rotate-90"}`}
/>
<span className="text-[13px] font-medium text-ink">Raisonnement</span>
{live && (
<span className="flex items-center gap-1 text-[12px] text-accent">
<span className="h-2 w-2 animate-pulse border border-line bg-accent" />
en cours…
</span>
)}
<span className="ml-auto text-[12px] text-muted-3">
{open ? "réduire" : "afficher"}
</span>
</button>
{open && (
<div
ref={bodyRef}
className="scr max-h-[200px] min-h-[60px] resize-y overflow-auto whitespace-pre-wrap border-t border-line-soft bg-card-deep px-3 py-2 text-[12.5px] leading-relaxed text-on-dark-2"
>
{text}
</div>
)}
</div>
);
}
function ShellConfirm({
command,
onApprove,
onReject,
}: {
command: string;
onApprove: () => void;
onReject: () => void;
}) {
return (
<div className="ml-[46px] overflow-hidden border border-line bg-card shadow-hard" style={{ borderRadius: 7 }}>
<div className="flex items-center gap-2 border-b border-line px-3 py-2.5">
<span className="text-[14px] text-accent">run_shell</span>
<span className="text-[13px] text-muted-2">· commande sensible à valider</span>
</div>
<div className="px-3 py-3">
<pre className="m-0 mb-3 overflow-auto whitespace-pre-wrap border border-line bg-card-deep px-3 py-2.5 text-[12.5px] text-on-dark">
$ {command}
</pre>
<div className="flex items-center gap-2">
<button
onClick={onApprove}
className="flex h-[32px] items-center gap-1.5 border border-line bg-accent px-3.5 text-[13px] text-white shadow-hard-accent"
style={{ borderRadius: 7 }}
>
Approuver &amp; exécuter
</button>
<button
onClick={onReject}
className="flex h-[32px] items-center border border-line bg-card px-3.5 text-[13px] text-ink-2"
style={{ borderRadius: 7 }}
>
Refuser
</button>
</div>
</div>
</div>
);
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-7 items-center gap-1.5 border border-line bg-card px-2.5 text-[13px] text-ink-2">
{children}
</div>
);
}
-414
View File
@@ -1,414 +0,0 @@
import { useEffect, useState } from "react";
import { useStore } from "../store/useStore";
import {
downloadFile,
getGitDiff,
getGitLog,
revertCommit,
type GitCommit,
type ToolCall,
} from "../api/client";
import { DownloadIcon, TrashIcon } from "../components/Icon";
import { FileTree } from "../components/FileTree";
type TabId = "preview" | "code" | "files" | "logs" | "git";
/** Panneau droit : onglets Aperçu / Code / Fichiers / Logs / Git. */
export function PreviewPanel() {
const { previewPath, previewContent, messages, streamTools, removeFile } =
useStore();
const [tab, setTab] = useState<TabId>("preview");
const [width, setWidth] = useState(() => {
const saved = Number(window.localStorage.getItem("loki.preview.width"));
return Number.isFinite(saved) && saved >= 300 ? saved : 452;
});
const updateWidth = (next: number) => {
const bounded = Math.max(300, Math.min(next, window.innerWidth - 520));
setWidth(bounded);
window.localStorage.setItem("loki.preview.width", String(bounded));
};
const startResize = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
const onMove = (move: PointerEvent) =>
updateWidth(startWidth + startX - move.clientX);
const onUp = () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const isHtml = previewPath ? /\.html?$/.test(previewPath) : false;
const [collapsed, setCollapsed] = useState(
() => window.localStorage.getItem("loki.preview.collapsed") === "1"
);
const toggleCollapsed = () => {
setCollapsed((c) => {
window.localStorage.setItem("loki.preview.collapsed", c ? "0" : "1");
return !c;
});
};
// Journal d'activité : tous les appels d'outils de la session + en cours.
const logs: ToolCall[] = [
...messages.flatMap((m) => m.meta?.tools ?? []),
...streamTools,
];
// Replié : barre fine, aucun contenu monté (pas d'iframe vivante).
if (collapsed) {
return (
<div className="flex w-9 flex-none flex-col items-center border-l border-line bg-panel pt-3">
<button
onClick={toggleCollapsed}
title="Déplier l'aperçu"
className="text-[15px] text-muted-2 hover:text-accent"
>
⇤
</button>
<div className="mt-3 rotate-90 whitespace-nowrap text-[10px] font-bold tracking-wide text-muted-3">
APERÇU
</div>
</div>
);
}
return (
<div
className="relative flex flex-none flex-col border-l border-line bg-panel"
style={{ width }}
>
<div
role="separator"
aria-label="Redimensionner le panneau d'aperçu"
aria-orientation="vertical"
tabIndex={0}
onPointerDown={startResize}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") updateWidth(width + 20);
if (event.key === "ArrowRight") updateWidth(width - 20);
}}
className="absolute -left-[6px] top-0 z-20 h-full w-[9px] cursor-col-resize bg-transparent hover:bg-accent/50 focus:bg-accent/50 focus:outline-none"
title="Glisser pour redimensionner"
/>
{/* Onglets */}
<div className="flex h-[46px] flex-none items-center gap-1.5 border-b border-line bg-base px-3">
<Tab active={tab === "preview"} onClick={() => setTab("preview")}>
Aperçu
</Tab>
<Tab active={tab === "code"} onClick={() => setTab("code")}>
Code
</Tab>
<Tab active={tab === "files"} onClick={() => setTab("files")}>
Fichiers
</Tab>
<Tab active={tab === "logs"} onClick={() => setTab("logs")}>
Logs
<span className="font-pixel ml-1.5 border border-line bg-accent px-1 py-0.5 text-[8px] text-white">
{logs.length}
</span>
</Tab>
<Tab active={tab === "git"} onClick={() => setTab("git")}>
Git
</Tab>
<div className="flex-1" />
<button
onClick={toggleCollapsed}
title="Replier l'aperçu"
className="px-2 text-[15px] text-muted-2 hover:text-accent"
>
⇥
</button>
</div>
{/* URL bar */}
<div className="flex flex-none items-center gap-2 px-3 py-2.5">
<div className="flex h-8 flex-1 items-center gap-2 border border-line bg-card px-[11px]">
<span className="text-[13px]">🔒</span>
<span className="truncate text-[13px] text-muted-2">
{previewPath ? `workspace/${previewPath}` : "workspace/"}
</span>
</div>
{previewPath && (
<>
<button
onClick={() => downloadFile(previewPath, useStore.getState().currentProject())}
className="flex h-8 items-center gap-1.5 border border-line bg-card px-2.5 text-[12px] text-accent"
title={`Télécharger ${previewPath}`}
>
<DownloadIcon size={13} />
Télécharger
</button>
<button
onClick={() => {
if (window.confirm(`Supprimer ${previewPath} ?`)) {
void removeFile(previewPath);
}
}}
className="flex h-8 items-center gap-1.5 border border-line bg-card px-2.5 text-[12px] text-warn"
title={`Supprimer ${previewPath}`}
>
<TrashIcon size={13} />
Supprimer
</button>
</>
)}
</div>
{/* Onglet Git */}
{tab === "git" ? (
<GitPanel />
) : tab === "files" ? (
<FilesTab />
) : /* Onglet Logs (fond sombre) */
tab === "logs" ? (
<div className="scr mx-3 mb-3 flex-1 overflow-auto border border-line bg-card-deep p-3 text-[12.5px]">
{logs.length === 0 ? (
<div className="py-10 text-center text-on-dark-3">
Aucune activité d'outil pour cette session.
</div>
) : (
logs.map((l, i) => (
<div
key={i}
className="flex items-center gap-2 border-b border-chrome-2 py-[7px] last:border-0"
>
<span
className={
l.status === "error" || l.status === "pending"
? "text-accent"
: l.status === "running"
? "text-on-dark-2"
: "text-ok"
}
>
{l.status === "error"
? "✕"
: l.status === "pending"
? "⏸"
: l.status === "running"
? "…"
: "✓"}
</span>
<span className="text-on-dark">{l.name}</span>
<span className="flex-1 truncate text-on-dark-3">
{(l.args?.path as string) ??
(l.args?.query as string) ??
(l.args?.command as string) ??
""}
</span>
<span className="text-on-dark-3">{l.summary}</span>
</div>
))
)}
</div>
) : (
/* Viewport Aperçu / Code (fond clair) */
<div className="scr mx-3 mb-3 flex-1 overflow-auto border border-line bg-panel shadow-hard">
{!previewPath ? (
<Empty>
L'aperçu s'affichera ici dès que l'agent générera un fichier (clique
aussi un fichier à gauche).
</Empty>
) : tab === "code" ? (
<pre className="m-0 whitespace-pre-wrap p-4 text-[12px] leading-relaxed text-ink">
{previewContent}
</pre>
) : isHtml ? (
<iframe
title="aperçu"
srcDoc={previewContent}
className="h-full w-full border-0 bg-white"
// allow-scripts : sans ça, le JS de la page générée ne s'exécute pas
// (animations, canvas…). On garde une origine opaque (pas
// d'allow-same-origin) pour que le script ne puisse pas atteindre Loki.
sandbox="allow-scripts"
/>
) : (
<pre className="m-0 whitespace-pre-wrap p-4 text-[12px] leading-relaxed text-ink">
{previewContent}
</pre>
)}
</div>
)}
</div>
);
}
/** Onglet Git : historique des commits, diff colorisé, retour arrière. */
/** Onglet Fichiers : arborescence du workspace, à côté de Code. */
function FilesTab() {
const { fileTree, refreshFiles } = useStore();
useEffect(() => {
refreshFiles();
}, [refreshFiles]);
return (
<div className="scr mx-3 mb-3 flex-1 overflow-auto rounded-card border border-line bg-panel p-2">
{fileTree.length === 0 ? (
<div className="px-2 py-8 text-center text-[13px] text-muted-2">
Le workspace est vide.
<br />
L'agent créera des fichiers ici.
</div>
) : (
<FileTree nodes={fileTree} />
)}
</div>
);
}
function GitPanel() {
const refreshFiles = useStore((s) => s.refreshFiles);
const currentProject = useStore((s) => s.currentProject);
const project = currentProject();
const [commits, setCommits] = useState<GitCommit[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [diff, setDiff] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = () => getGitLog(project).then(setCommits).catch(() => {});
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project]);
const openDiff = async (hash: string) => {
setSelected(hash);
setDiff("Chargement…");
setDiff((await getGitDiff(hash, project)) || "(diff vide)");
};
const doRevert = async (hash: string) => {
if (!window.confirm(`Annuler le commit ${hash.slice(0, 7)} ? (crée un commit inverse)`))
return;
setBusy(hash);
setError(null);
try {
await revertCommit(hash, project);
await load();
await refreshFiles();
if (selected === hash) setSelected(null);
} catch (e) {
setError(e instanceof Error ? e.message : "revert impossible");
} finally {
setBusy(null);
}
};
return (
<div className="scr mx-3 mb-3 flex flex-1 flex-col overflow-hidden border border-line bg-card">
{error && (
<div className="border-b border-line bg-warn/10 px-3 py-2 text-[12px] text-warn">
{error}
</div>
)}
{/* Liste des commits */}
<div className="scr max-h-[45%] overflow-auto border-b border-line">
{commits.length === 0 ? (
<div className="p-4 text-center text-[12px] text-muted-2">
Aucun commit. L'agent commitera chaque modification de code ici.
</div>
) : (
commits.map((c) => (
<div
key={c.full_hash}
className={`flex items-center gap-2 border-b border-line-soft px-3 py-2 last:border-0 ${
selected === c.hash ? "bg-base" : ""
}`}
>
<button
onClick={() => openDiff(c.hash)}
className="min-w-0 flex-1 text-left"
title={c.subject}
>
<div className="truncate text-[13px] text-ink">{c.subject}</div>
<div className="text-[11px] text-muted-2">
{c.hash} · {c.when} · {c.files_changed} fichier
{c.files_changed > 1 ? "s" : ""}
</div>
</button>
<button
onClick={() => doRevert(c.hash)}
disabled={busy === c.hash}
className="flex-none border border-line bg-card px-2 py-1 text-[11px] text-warn disabled:opacity-40"
title="Annuler ce commit (revert)"
>
{busy === c.hash ? "…" : "↶ Annuler"}
</button>
</div>
))
)}
</div>
{/* Diff du commit sélectionné */}
<div className="scr flex-1 overflow-auto bg-card-deep p-3">
{selected ? (
<DiffView diff={diff} />
) : (
<div className="py-8 text-center text-[12px] text-on-dark-3">
Clique un commit pour voir ses modifications.
</div>
)}
</div>
</div>
);
}
/** Rendu coloré d'un diff unifié. */
function DiffView({ diff }: { diff: string }) {
return (
<pre className="m-0 whitespace-pre-wrap text-[11.5px] leading-relaxed">
{diff.split("\n").map((line, i) => {
let color = "text-on-dark-2";
if (line.startsWith("+") && !line.startsWith("+++")) color = "text-ok";
else if (line.startsWith("-") && !line.startsWith("---")) color = "text-accent";
else if (line.startsWith("@@")) color = "text-info";
else if (/^(commit|Author|Date|diff|index)/.test(line))
color = "text-on-dark-3";
return (
<div key={i} className={color}>
{line || " "}
</div>
);
})}
</pre>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<div className="font-pixel text-[10px] text-label">AUCUN APERÇU</div>
<div className="max-w-[240px] text-[13px] text-muted-2">{children}</div>
</div>
);
}
function Tab({
children,
active,
onClick,
}: {
children: React.ReactNode;
active?: boolean;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex h-[30px] items-center border border-line px-[13px] text-[13px] ${
active ? "bg-card-deep text-ink" : "bg-card text-muted"
}`}
>
{children}
</button>
);
}
File diff suppressed because it is too large. Load diff
-550
View File
@@ -1,550 +0,0 @@
import { create } from "zustand";
import {
createSession,
deleteFile,
deleteSession,
getConfig,
listProjects,
setSessionProject,
getModels,
getSession,
getStatus,
getSystemStats,
getLoadedModels,
getPulse,
warmModel,
fileContent,
listFiles,
listSessions,
renameSession as apiRenameSession,
runShell,
saveConfig,
streamChat,
type AgentConfig,
type FileNode,
type Message,
type OllamaModel,
type OllamaStatus,
type Session,
type LoadedModel,
type SystemStats,
type ToolCall,
} from "../api/client";
interface LokiState {
status: OllamaStatus | null;
systemStats: SystemStats | null;
loadedModels: LoadedModel[];
warmingModel: string | null;
warmError: string | null;
models: OllamaModel[];
selectedModel: string;
loadingModels: boolean;
sessions: Session[];
currentSessionId: string | null;
messages: Message[];
streaming: boolean;
streamingSessionId: string | null;
streamContent: string; // réponse de l'agent en cours de frappe
streamThinking: string; // raisonnement de l'agent en cours
streamStatus: string;
streamNotice: string | null;
streamTools: ToolCall[]; // appels d'outils de la réponse en cours
streamPlan: string[]; // plan de la réponse en cours
streamPlanDone: number[]; // index des étapes validées en direct
fileTree: FileNode[];
previewPath: string | null;
previewContent: string;
config: AgentConfig | null;
availableTools: string[];
refreshConfig: () => Promise<void>;
updateConfig: (patch: Partial<AgentConfig>) => Promise<void>;
pendingShell: string | null; // commande shell en attente de validation
approveShell: () => Promise<void>;
rejectShell: () => Promise<void>;
openPreview: (path: string) => Promise<void>;
setSelectedModel: (name: string) => void;
refreshStatus: () => Promise<void>;
refreshSystemStats: () => Promise<void>;
refreshLoadedModels: () => Promise<void>;
refreshPulse: () => Promise<void>;
refreshModels: () => Promise<void>;
refreshFiles: () => Promise<void>;
refreshSessions: () => Promise<void>;
newSession: () => Promise<void>;
openSession: (id: string) => Promise<void>;
removeSession: (id: string) => Promise<void>;
renameSession: (id: string, title: string) => Promise<void>;
removeFile: (path: string) => Promise<void>;
projects: { name: string; files: number }[];
refreshProjects: () => Promise<void>;
currentProject: () => string | null;
setProject: (name: string | null) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
stopStreaming: () => void;
mode: "plan" | "build" | "yolo";
setMode: (m: "plan" | "build" | "yolo") => void;
}
let activeStreamController: AbortController | null = null;
export const useStore = create<LokiState>((set, get) => ({
status: null,
systemStats: null,
loadedModels: [],
warmingModel: null,
warmError: null,
models: [],
selectedModel: "",
loadingModels: false,
sessions: [],
currentSessionId: null,
messages: [],
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
streamPlan: [],
streamPlanDone: [],
fileTree: [],
previewPath: null,
previewContent: "",
projects: [],
config: null,
availableTools: [],
pendingShell: null,
mode: "build",
setMode: (m) => set({ mode: m }),
approveShell: async () => {
const cmd = get().pendingShell;
if (!cmd) return;
set({ pendingShell: null });
let report: string;
try {
const r = await runShell(cmd);
report =
`J'ai validé la commande \`${cmd}\` (code ${r.exit_code}).\n` +
`Sortie :\n\`\`\`\n${r.output || "(vide)"}\n\`\`\``;
} catch {
report = `Échec de l'exécution de \`${cmd}\`.`;
}
await get().refreshFiles();
// On renvoie le résultat à l'agent pour qu'il poursuive.
await get().sendMessage(report);
},
rejectShell: async () => {
const cmd = get().pendingShell;
if (!cmd) return;
set({ pendingShell: null });
await get().sendMessage(`J'ai refusé la commande \`${cmd}\`. N'exécute pas cette commande.`);
},
refreshConfig: async () => {
const model = get().selectedModel;
const { config, available_tools } = await getConfig(model || undefined);
if (get().selectedModel === model) {
set({ config, availableTools: available_tools });
}
},
updateConfig: async (patch) => {
const config = await saveConfig(patch, get().selectedModel || undefined);
set({ config });
},
openPreview: async (path) => {
const content = await fileContent(path, get().currentProject());
set({ previewPath: path, previewContent: content });
},
setSelectedModel: (name) => {
set({ selectedModel: name, warmingModel: name || null, warmError: null });
void (async () => {
try {
await get().refreshConfig();
if (!name || get().selectedModel !== name) return;
// La configuration est propre au modèle : on attend son chargement avant
// d'utiliser keep_alive, sinon la valeur du modèle précédent est envoyée.
const ka = get().config?.keep_alive ?? "30m";
const st = await warmModel(name, ka);
await get().refreshLoadedModels();
// Modèle chargé hors GPU : prévenir (souvent trop gros pour la VRAM).
if (
(st.processor === "cpu" || st.processor === "mixte") &&
get().selectedModel === name
) {
set({
warmError:
st.processor === "cpu"
? "Modèle chargé sur le CPU (trop gros pour la VRAM) — lent. Choisis un modèle plus petit ou une quantization plus légère."
: `Modèle en partie sur GPU (${st.gpu_percent ?? "?"}%) : dépasse la VRAM, chargement plus lent.`,
});
}
} catch (err) {
if (get().selectedModel === name) {
set({
warmError: err instanceof Error ? err.message : "préchargement impossible",
});
}
} finally {
if (get().warmingModel === name) set({ warmingModel: null });
}
})();
},
refreshFiles: async () => {
try {
set({ fileTree: await listFiles(get().currentProject()) });
} catch {
/* workspace indisponible */
}
},
refreshProjects: async () => {
try {
const { projects } = await listProjects();
set({ projects });
} catch {
/* backend indisponible */
}
},
currentProject: () => {
const s = get().sessions.find((x) => x.id === get().currentSessionId);
return s?.project ?? null;
},
setProject: async (name) => {
const sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined, name);
set({ currentSessionId: s.id, messages: [] });
} else {
await setSessionProject(sid, name);
}
await get().refreshSessions();
set({ previewPath: null, previewContent: "" });
await get().refreshFiles();
},
refreshStatus: async () => {
try {
const status = await getStatus();
set({ status });
} catch {
set({ status: { connected: false, host: "", default_model: "" } });
}
},
refreshSystemStats: async () => {
try {
set({ systemStats: await getSystemStats() });
} catch {
set({ systemStats: null });
}
},
refreshLoadedModels: async () => {
set({ loadedModels: await getLoadedModels() });
},
// Sondage unique (statut + ressources + modèles chargés). Une seule requête
// au lieu de trois, et une seule mise à jour du store : moins de réveils
// navigateur et moins de rendus React quand l'app est ouverte à vide.
refreshPulse: async () => {
try {
const { status, stats, loaded } = await getPulse();
set({ status, systemStats: stats, loadedModels: loaded });
} catch {
set({
status: { connected: false, host: "", default_model: "" },
systemStats: null,
});
}
},
refreshModels: async () => {
set({ loadingModels: true });
try {
const { models, default: def } = await getModels();
const installed = new Set(models.map((model) => model.name));
const current = get().selectedModel;
const selectedModel = installed.has(current)
? current
: installed.has(def)
? def
: models[0]?.name ?? "";
set({ models });
// Passe par l'action de sélection afin de précharger aussi le modèle choisi
// automatiquement au démarrage (défaut ou premier modèle installé).
get().setSelectedModel(selectedModel);
} finally {
set({ loadingModels: false });
}
},
refreshSessions: async () => {
const sessions = await listSessions();
set({ sessions });
},
newSession: async () => {
// La nouvelle session hérite du projet de la session courante.
const s = await createSession(
get().selectedModel || undefined, get().currentProject()
);
const streaming = get().streaming;
set({
currentSessionId: s.id,
messages: [],
...(streaming
? {}
: { streamContent: "", streamStatus: "", streamNotice: null }),
});
await get().refreshSessions();
},
openSession: async (id) => {
const { messages } = await getSession(id);
const streaming = get().streaming;
set({
currentSessionId: id,
messages,
// Une validation shell en attente appartient à la session quittée.
...(streaming
? {}
: { streamContent: "", streamStatus: "", streamNotice: null, pendingShell: null }),
});
// L'arborescence suit le projet de la session ouverte.
await get().refreshFiles();
},
removeSession: async (id) => {
await deleteSession(id);
if (get().currentSessionId === id) {
set({ currentSessionId: null, messages: [] });
}
await get().refreshSessions();
},
renameSession: async (id, title) => {
const clean = title.trim();
if (!clean) return;
await apiRenameSession(id, clean);
await get().refreshSessions();
},
removeFile: async (path) => {
await deleteFile(path, get().currentProject());
// Ferme l'aperçu si le fichier supprimé (ou son dossier) y est affiché.
const preview = get().previewPath;
if (preview && (preview === path || preview.startsWith(path + "/"))) {
set({ previewPath: null, previewContent: "" });
}
await get().refreshFiles();
},
stopStreaming: () => {
activeStreamController?.abort();
activeStreamController = null;
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
streamPlan: [],
pendingShell: null,
});
void get().refreshSessions();
},
sendMessage: async (content) => {
if (get().streaming) return;
content = content.trim();
if (!content) return;
if (!get().selectedModel) {
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: get().currentSessionId ?? "",
role: "assistant",
content: "⚠️ Aucun modèle Ollama installé ou sélectionné.",
created_at: Date.now() / 1000,
};
set({ messages: [...get().messages, errMsg] });
return;
}
// Crée une session à la volée si aucune n'est ouverte.
let sid = get().currentSessionId;
try {
if (!sid) {
const s = await createSession(get().selectedModel || undefined);
sid = s.id;
set({ currentSessionId: s.id });
}
} catch (err) {
const detail = err instanceof Error ? err.message : "backend injoignable";
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: "",
role: "assistant",
content: `⚠️ Impossible de créer la session : ${detail}`,
created_at: Date.now() / 1000,
};
set({ messages: [...get().messages, errMsg] });
return;
}
// 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,
streamingSessionId: sid,
streamContent: "",
streamThinking: "",
streamStatus: "Connexion à Ollama…",
streamNotice: null,
streamTools: [],
streamPlan: [],
streamPlanDone: [],
pendingShell: null,
});
const controller = new AbortController();
activeStreamController = controller;
await streamChat(
{
session_id: sid,
content,
model: get().selectedModel || undefined,
mode: get().mode,
},
{
onToken: (t) => set({ streamContent: get().streamContent + t }),
onThinking: (t) => set({ streamThinking: get().streamThinking + t }),
onStatus: (message) => set({ streamStatus: message }),
onPlan: (steps) => set({ streamPlan: steps, streamPlanDone: [] }),
onPlanStep: (index) =>
set({
streamPlanDone: get().streamPlanDone.includes(index)
? get().streamPlanDone
: [...get().streamPlanDone, index],
}),
onRevision: (content) => set({ streamContent: content }),
onNotice: (message) => set({ streamNotice: message }),
onToolCall: (call) =>
set({ streamTools: [...get().streamTools, call] }),
onToolResult: (call) => {
// Met à jour le dernier outil correspondant (statut + résumé).
const tools = [...get().streamTools];
for (let i = tools.length - 1; i >= 0; i--) {
if (tools[i].name === call.name && tools[i].status === "running") {
tools[i] = { ...tools[i], ...call };
break;
}
}
set({ streamTools: tools });
},
onToolConfirm: (command) => set({ pendingShell: command }),
onDone: async () => {
activeStreamController = null;
// Repère un fichier HTML écrit pour l'afficher automatiquement.
const writtenHtml = [...get().streamTools]
.reverse()
.find(
(t) =>
t.name === "write_file" &&
typeof t.args?.path === "string" &&
/\.html?$/.test(t.args.path as string)
);
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
streamPlan: [],
streamPlanDone: [],
});
// Recharge depuis la base + l'arborescence (fichiers créés par
// l'agent) — trois requêtes indépendantes, en parallèle.
await Promise.all([
get().currentSessionId === sid
? get().openSession(sid!)
: Promise.resolve(),
get().refreshSessions(),
get().refreshFiles(),
]);
if (writtenHtml) await get().openPreview(writtenHtml.args.path as string);
},
onError: (msg) => {
activeStreamController = null;
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: sid!,
role: "assistant",
content: `⚠️ Erreur : ${msg}`,
created_at: Date.now() / 1000,
};
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
messages:
get().currentSessionId === sid
? [...get().messages, errMsg]
: get().messages,
});
void get().refreshSessions();
},
onAbort: () => {
activeStreamController = null;
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
pendingShell: null,
});
void get().refreshSessions();
},
},
controller.signal
);
},
}));
-88
View File
@@ -1,88 +0,0 @@
import type { Config } from "tailwindcss";
/**
* Système de design « Loki » — thème Nocturne clair (Claude Design).
*
* Reprend les jetons de `Loki App.dc.html` : fond gris froid, surfaces
* blanches, filets 1 px, accent bleu ardoise et ombres douces diffuses.
* Remplace le thème néo-brutaliste (bordures noires 3 px, ombres dures
* décalées, accent orange, police pixel).
*
* Les NOMS de jetons sont inchangés : seule leur valeur bascule, ce qui fait
* suivre toute l'interface sans réécrire les composants. Les jetons
* historiquement « sombres » (card-deep, on-dark…) deviennent la surface
* sélectionnée claire et son encre, conformément à la maquette.
*/
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// Fonds
base: "#f7f8fa", // --color-bg
bar: "#ffffff", // barres : surface + filet (plus de chrome sombre)
panel: "#ffffff", // --color-surface
sunken: "#f1f3f6", // encarts (code, invite système) : neutral-900
// Surfaces / cartes
card: "#ffffff",
"card-soft": "#f7f8fa",
"card-deep": "#eef2f9", // sélection : accent-900
// Filets
line: "#e3e6eb", // --color-divider
"line-soft": "#e6e9ee", // neutral-800
"line-strong": "#c3c9d2", // neutral-700
// Accent bleu ardoise
accent: "#2f4a7a", // --color-accent
"accent-1": "#4a6797", // accent-600
"accent-2": "#26406b", // accent-300
"accent-ghost": "#eef2f9", // accent-900 (fonds teintés)
"accent-line": "#b9c6dd", // accent-700 (bordure de sélection)
// Statuts, accordés à la palette
ok: "#2f7a5a",
info: "#2f4a7a",
warn: "#b3403a",
// Encres
ink: "#1c2536", // --color-text / neutral-100
"ink-2": "#29334a", // neutral-200
"ink-3": "#3d4a5e", // neutral-300
muted: "#55627a", // neutral-400
"muted-2": "#6b7686", // neutral-500
"muted-3": "#8b95a4", // neutral-600
"muted-4": "#8b95a4",
label: "#8b95a4", // couleur des « kickers »
// Anciennes teintes « sur fond sombre » -> encres sur surface claire
"chrome-2": "#e3e6eb",
"chrome-3": "#c3c9d2",
"on-dark": "#1c2536",
"on-dark-2": "#3d4a5e",
"on-dark-3": "#6b7686",
},
fontFamily: {
sans: ['"Inter"', "ui-sans-serif", "system-ui", "sans-serif"],
mono: ["ui-monospace", '"SF Mono"', "Menlo", "monospace"],
// Ex-police pixel : devient le « kicker » de la maquette — petites
// capitales très espacées. Les libellés existants suivent sans edit.
pixel: [
'"Inter"',
{ letterSpacing: "0.14em", fontWeight: "500" },
],
},
borderRadius: {
card: "10px", // --radius-md
},
boxShadow: {
// Ombres douces diffuses (--shadow-sm/md/lg) au lieu des ombres dures.
"hard-sm": "0 1px 2px rgba(28, 37, 54, 0.06)",
hard: "0 2px 8px rgba(28, 37, 54, 0.08)",
"hard-lg": "0 8px 28px rgba(28, 37, 54, 0.10)",
"hard-accent": "0 2px 8px rgba(47, 74, 122, 0.18)",
"accent-soft": "0 1px 2px rgba(47, 74, 122, 0.14)",
frame: "0 2px 8px rgba(28, 37, 54, 0.08)",
},
backgroundImage: {
"accent-grad": "linear-gradient(140deg, #4a6797, #2f4a7a)",
},
},
},
plugins: [],
} satisfies Config;
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
-13
View File
@@ -1,13 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// En dev, on proxifie /api vers le backend FastAPI (port 8080).
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://localhost:8080",
},
},
});