Add workspace navigation and model management

This commit is contained in:
Michael committed 2026-06-30 16:28:44 +02:00
1 parent 765c7b31d5
commit a7290039f6
10 files changed
+372 -49

No files matched your search

+9
View File
@@ -79,6 +79,15 @@ class OllamaClient:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
async def delete_model(self, name: str) -> dict:
"""Supprime un modèle installé (/api/delete)."""
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
resp = await client.request(
"DELETE", f"{self.host}/api/delete", json={"model": name}
)
resp.raise_for_status()
return resp.json() if resp.content else {"status": "success"}
async def pull_model(self, name: str) -> AsyncIterator[dict]: async def pull_model(self, name: str) -> AsyncIterator[dict]:
"""Télécharge un modèle en streamant la progression (/api/pull).""" """Télécharge un modèle en streamant la progression (/api/pull)."""
async with httpx.AsyncClient( async with httpx.AsyncClient(
+17
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import os import os
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from ..config import settings from ..config import settings
from ..tools import ToolError, _safe_path from ..tools import ToolError, _safe_path
@@ -47,3 +48,19 @@ async def file_content(path: str) -> dict:
raise HTTPException(404, "fichier introuvable") raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f: with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()} return {"path": path, "content": f.read()}
@router.get("/download")
async def download_file(path: str) -> FileResponse:
"""Télécharge un fichier en conservant le confinement au workspace."""
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
return FileResponse(
target,
filename=os.path.basename(target),
media_type="application/octet-stream",
)
+20 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import json import json
import httpx import httpx
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -62,6 +62,25 @@ class PullRequest(BaseModel):
name: str name: str
class DeleteModelRequest(BaseModel):
name: str
@router.delete("/models")
async def delete_model(req: DeleteModelRequest) -> dict:
"""Supprime explicitement un modèle de l'instance Ollama."""
if not req.name.strip():
raise HTTPException(400, "nom de modèle vide")
try:
await ollama.delete_model(req.name.strip())
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500] or str(exc)
raise HTTPException(502, f"Ollama : {detail}") from exc
except (httpx.HTTPError, OSError) as exc:
raise HTTPException(502, f"Ollama injoignable : {exc}") from exc
return {"deleted": req.name.strip()}
@router.post("/models/pull") @router.post("/models/pull")
async def pull_model(req: PullRequest) -> StreamingResponse: async def pull_model(req: PullRequest) -> StreamingResponse:
"""Télécharge un modèle en streamant la progression (SSE).""" """Télécharge un modèle en streamant la progression (SSE)."""
+11 -3
View File
@@ -5,6 +5,7 @@ import { LeftPanel } from "./panels/LeftPanel";
import { ChatPanel } from "./panels/ChatPanel"; import { ChatPanel } from "./panels/ChatPanel";
import { PreviewPanel } from "./panels/PreviewPanel"; import { PreviewPanel } from "./panels/PreviewPanel";
import { SettingsView } from "./panels/SettingsView"; import { SettingsView } from "./panels/SettingsView";
import { FilesView, HistoryView, ToolsView } from "./panels/ActivityViews";
import { useStore } from "./store/useStore"; import { useStore } from "./store/useStore";
export default function App() { export default function App() {
@@ -26,15 +27,22 @@ export default function App() {
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<ActivityBar active={view} onChange={setView} /> <ActivityBar active={view} onChange={setView} />
{view === "settings" ? ( {view === "chat" && (
<SettingsView />
) : (
<> <>
<LeftPanel /> <LeftPanel />
<ChatPanel /> <ChatPanel />
<PreviewPanel /> <PreviewPanel />
</> </>
)} )}
{view === "history" && <HistoryView onOpen={() => setView("chat")} />}
{view === "files" && (
<>
<FilesView />
<PreviewPanel />
</>
)}
{view === "tools" && <ToolsView onSettings={() => setView("settings")} />}
{view === "settings" && <SettingsView />}
</div> </div>
</div> </div>
); );
+21
View File
@@ -29,6 +29,18 @@ export async function getModels(): Promise<{
return res.json(); 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 { export interface Session {
id: string; id: string;
title: string; title: string;
@@ -127,6 +139,15 @@ export async function fileContent(path: string): Promise<string> {
return (await res.json()).content; return (await res.json()).content;
} }
export function downloadFile(path: string): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export async function listSessions(): Promise<Session[]> { export async function listSessions(): Promise<Session[]> {
const res = await fetch("/api/sessions"); const res = await fetch("/api/sessions");
return (await res.json()).sessions; return (await res.json()).sessions;
+9 -5
View File
@@ -8,11 +8,11 @@ import {
export type View = "chat" | "history" | "files" | "tools" | "settings"; export type View = "chat" | "history" | "files" | "tools" | "settings";
const items: { id: View; icon: React.ReactNode }[] = [ const items: { id: View; label: string; icon: React.ReactNode }[] = [
{ id: "chat", icon: <ChatIcon /> }, { id: "chat", label: "Chat", icon: <ChatIcon /> },
{ id: "history", icon: <ClockIcon /> }, { id: "history", label: "Historique", icon: <ClockIcon /> },
{ id: "files", icon: <FilesIcon /> }, { id: "files", label: "Fichiers", icon: <FilesIcon /> },
{ id: "tools", icon: <NodesIcon /> }, { id: "tools", label: "Outils", icon: <NodesIcon /> },
]; ];
const btn = (on: boolean) => const btn = (on: boolean) =>
@@ -37,6 +37,8 @@ export function ActivityBar({
key={it.id} key={it.id}
onClick={() => onChange(it.id)} onClick={() => onChange(it.id)}
className={btn(active === it.id)} className={btn(active === it.id)}
title={it.label}
aria-label={it.label}
> >
{it.icon} {it.icon}
</button> </button>
@@ -47,6 +49,8 @@ export function ActivityBar({
<button <button
onClick={() => onChange("settings")} onClick={() => onChange("settings")}
className={btn(active === "settings")} className={btn(active === "settings")}
title="Configuration"
aria-label="Configuration"
> >
<SettingsIcon /> <SettingsIcon />
</button> </button>
+158
View File
@@ -0,0 +1,158 @@
import { useEffect } from "react";
import { downloadFile, type FileNode } from "../api/client";
import { DownloadIcon } from "../components/Icon";
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-[3px] 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-2 border-line bg-accent px-3 py-1.5 text-[12px] font-bold text-white"
>
Ouvrir
</button>
<button
onClick={() => removeSession(session.id)}
className="border-2 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 FilesView() {
const { fileTree, refreshFiles } = useStore();
useEffect(() => {
refreshFiles();
}, [refreshFiles]);
return (
<Page title="Fichiers" subtitle="Workspace créé et modifié par l’agent.">
<div className="border-[3px] border-line bg-panel p-3 shadow-hard">
{fileTree.length ? <WorkspaceTree nodes={fileTree} depth={0} /> : <Empty text="Le workspace est vide." />}
</div>
</Page>
);
}
function WorkspaceTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
const { openPreview, previewPath } = useStore();
return (
<>
{nodes.map((node) => (
<div key={node.path}>
<div
className={`mb-1 flex items-center gap-2 border-2 px-3 py-2 ${
node.path === previewPath
? "border-accent bg-card-deep text-white"
: "border-line bg-card text-ink-2"
}`}
style={{ marginLeft: depth * 18 }}
>
<button
onClick={() => node.type === "file" && openPreview(node.path)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span>{node.type === "dir" ? "▾" : "▪"}</span>
<span className="truncate">{node.name}</span>
{node.size !== undefined && (
<span className="ml-auto text-[11px] text-muted-2">{formatSize(node.size)}</span>
)}
</button>
{node.type === "file" && (
<button
onClick={() => downloadFile(node.path)}
className="flex items-center gap-1 border-2 border-line bg-base px-2 py-1 text-[11px] text-accent"
title={`Télécharger ${node.name}`}
>
<DownloadIcon size={12} /> Télécharger
</button>
)}
</div>
{node.children && <WorkspaceTree nodes={node.children} depth={depth + 1} />}
</div>
))}
</>
);
}
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-[3px] 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-2 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-[3px] 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-2 border-line bg-card p-8 text-center text-muted-2">{text}</div>;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} Ko`;
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
}
+16 -4
View File
@@ -1,7 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import { PlusIcon } from "../components/Icon"; import { DownloadIcon, PlusIcon } from "../components/Icon";
import type { FileNode } from "../api/client"; import { downloadFile, type FileNode } from "../api/client";
/** Panneau gauche : historique des sessions + arborescence de fichiers. */ /** Panneau gauche : historique des sessions + arborescence de fichiers. */
export function LeftPanel() { export function LeftPanel() {
@@ -115,7 +115,7 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
<div key={n.path}> <div key={n.path}>
<div <div
onClick={() => n.type === "file" && openPreview(n.path)} onClick={() => n.type === "file" && openPreview(n.path)}
className={`mb-[3px] flex items-center gap-2 px-2 py-[5px] ${ className={`group mb-[3px] flex items-center gap-2 px-2 py-[5px] ${
n.type === "file" n.type === "file"
? active ? active
? "cursor-pointer border-2 border-line bg-card-deep text-white" ? "cursor-pointer border-2 border-line bg-card-deep text-white"
@@ -133,7 +133,19 @@ function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
}`} }`}
/> />
)} )}
<span className="truncate">{n.name}</span> <span className="min-w-0 flex-1 truncate">{n.name}</span>
{n.type === "file" && (
<button
onClick={(event) => {
event.stopPropagation();
downloadFile(n.path);
}}
className="hidden flex-none text-muted-2 hover:text-accent group-hover:block"
title={`Télécharger ${n.name}`}
>
<DownloadIcon size={13} />
</button>
)}
</div> </div>
{n.children && <FileTree nodes={n.children} depth={depth + 1} />} {n.children && <FileTree nodes={n.children} depth={depth + 1} />}
</div> </div>
+53 -2
View File
@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import type { ToolCall } from "../api/client"; import { downloadFile, type ToolCall } from "../api/client";
import { DownloadIcon } from "../components/Icon";
type TabId = "preview" | "code" | "logs"; type TabId = "preview" | "code" | "logs";
@@ -8,6 +9,30 @@ type TabId = "preview" | "code" | "logs";
export function PreviewPanel() { export function PreviewPanel() {
const { previewPath, previewContent, messages, streamTools } = useStore(); const { previewPath, previewContent, messages, streamTools } = useStore();
const [tab, setTab] = useState<TabId>("preview"); 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 isHtml = previewPath ? /\.html?$/.test(previewPath) : false;
@@ -18,7 +43,23 @@ export function PreviewPanel() {
]; ];
return ( return (
<div className="flex w-[452px] flex-none flex-col border-l-[3px] border-line bg-panel"> <div
className="relative flex flex-none flex-col border-l-[3px] 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 */} {/* Onglets */}
<div className="flex h-[46px] flex-none items-center gap-1.5 border-b-[3px] border-line bg-base px-3"> <div className="flex h-[46px] flex-none items-center gap-1.5 border-b-[3px] border-line bg-base px-3">
<Tab active={tab === "preview"} onClick={() => setTab("preview")}> <Tab active={tab === "preview"} onClick={() => setTab("preview")}>
@@ -43,6 +84,16 @@ export function PreviewPanel() {
{previewPath ? `workspace/${previewPath}` : "workspace/"} {previewPath ? `workspace/${previewPath}` : "workspace/"}
</span> </span>
</div> </div>
{previewPath && (
<button
onClick={() => downloadFile(previewPath)}
className="flex h-8 items-center gap-1.5 border-[3px] border-line bg-card px-2.5 text-[12px] text-accent"
title={`Télécharger ${previewPath}`}
>
<DownloadIcon size={13} />
Télécharger
</button>
)}
</div> </div>
{/* Onglet Logs (fond sombre) */} {/* Onglet Logs (fond sombre) */}
+58 -34
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useStore } from "../store/useStore"; import { useStore } from "../store/useStore";
import { pullModel } from "../api/client"; import { deleteModel, pullModel } from "../api/client";
import type { AgentConfig } from "../api/client"; import type { AgentConfig } from "../api/client";
import { DownloadIcon, RefreshIcon } from "../components/Icon"; import { DownloadIcon, RefreshIcon } from "../components/Icon";
@@ -40,6 +40,22 @@ export function SettingsView() {
const [pullName, setPullName] = useState(""); const [pullName, setPullName] = useState("");
const [pullStatus, setPullStatus] = useState<string | null>(null); const [pullStatus, setPullStatus] = useState<string | null>(null);
const [pullPct, setPullPct] = useState(0); const [pullPct, setPullPct] = useState(0);
const [deletingModel, setDeletingModel] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const doDeleteModel = async (name: string) => {
if (!window.confirm(`Supprimer définitivement le modèle ${name} ?`)) return;
setDeletingModel(name);
setDeleteError(null);
try {
await deleteModel(name);
await refreshModels();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "suppression impossible");
} finally {
setDeletingModel(null);
}
};
const doPull = async () => { const doPull = async () => {
if (!pullName.trim()) return; if (!pullName.trim()) return;
@@ -134,49 +150,57 @@ export function SettingsView() {
{models.map((m) => { {models.map((m) => {
const on = m.name === selectedModel; const on = m.name === selectedModel;
return ( return (
<button <div
key={m.name} key={m.name}
onClick={() => setSelectedModel(m.name)}
className={`flex items-center gap-3 border-[3px] border-line p-3 text-left ${ className={`flex items-center gap-3 border-[3px] border-line p-3 text-left ${
on ? "bg-card-deep" : "bg-card-soft" on ? "bg-card-deep" : "bg-card-soft"
}`} }`}
> >
<span <button
className={`flex h-[18px] w-[18px] flex-none items-center justify-center border-[3px] ${ onClick={() => setSelectedModel(m.name)}
on ? "border-accent" : "border-muted-3" className="flex min-w-0 flex-1 items-center gap-3 text-left"
}`}
> >
{on && <span className="h-2 w-2 bg-accent" />} <span
</span> className={`flex h-[18px] w-[18px] flex-none items-center justify-center border-[3px] ${
<div className="flex-1"> on ? "border-accent" : "border-muted-3"
<div className="flex items-center gap-2"> }`}
<span
className={`text-[14px] ${on ? "text-white" : "text-ink"}`}
>
{m.name}
</span>
{m.name === status?.default_model && (
<span className="font-pixel border-2 border-white bg-accent px-1.5 py-0.5 text-[7px] text-white">
DÉFAUT
</span>
)}
</div>
<div
className={`mt-1 text-[13px] ${on ? "text-on-dark-2" : "text-muted-2"}`}
> >
{[ {on && <span className="h-2 w-2 bg-accent" />}
m.size_go ? `${m.size_go} Go` : null, </span>
m.quantization, <div className="min-w-0 flex-1">
m.parameter_size, <div className="flex items-center gap-2">
m.family, <span className={`truncate text-[14px] ${on ? "text-white" : "text-ink"}`}>
] {m.name}
.filter(Boolean) </span>
.join(" · ")} {m.name === status?.default_model && (
<span className="font-pixel flex-none border-2 border-white bg-accent px-1.5 py-0.5 text-[7px] text-white">
DÉFAUT
</span>
)}
</div>
<div className={`mt-1 text-[13px] ${on ? "text-on-dark-2" : "text-muted-2"}`}>
{[m.size_go ? `${m.size_go} Go` : null, m.quantization, m.parameter_size, m.family]
.filter(Boolean)
.join(" · ")}
</div>
</div> </div>
</div> </button>
</button> <button
onClick={() => doDeleteModel(m.name)}
disabled={deletingModel === m.name}
className="flex-none border-2 border-line bg-base px-2 py-1 text-[12px] text-warn disabled:opacity-50"
title={`Supprimer ${m.name}`}
>
{deletingModel === m.name ? "…" : "Supprimer"}
</button>
</div>
); );
})} })}
{deleteError && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
{deleteError}
</div>
)}
</div> </div>
<div className="my-4 h-[3px] bg-line" /> <div className="my-4 h-[3px] bg-line" />