mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
Add workspace navigation and model management
This commit is contained in:
1 parent
765c7b31d5
commit
a7290039f6
10 files changed
+372
-49
No files matched your search
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) */}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in new issue
Block a user