Phase 1-2 : socle Loki, design system et connexion Ollama

- Scaffold full-stack : backend FastAPI + frontend React/Vite/TS/Tailwind
- Design system fidèle au thème (palette ambre/café, JetBrains Mono, tokens Tailwind)
- Layout 3 panneaux : activity bar, historique/fichiers, chat, aperçu
- Connexion Ollama : statut live, liste des modèles, pull avec progression SSE, sélecteur de modèle
- Vue Configuration (Frame 2) : modèles, génération, outils, invite système
- Dockerfile multi-stage + docker-compose (Ollama externe par défaut, profil ollama optionnel)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-29 16:51:25 +00:00
commit 3ad37ae2c7
34 files changed
+4343

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
import {
ChatIcon,
ClockIcon,
FilesIcon,
NodesIcon,
SettingsIcon,
} from "./Icon";
export type View = "chat" | "history" | "files" | "tools" | "settings";
const items: { id: View; icon: React.ReactNode }[] = [
{ id: "chat", icon: <ChatIcon /> },
{ id: "history", icon: <ClockIcon /> },
{ id: "files", icon: <FilesIcon /> },
{ id: "tools", icon: <NodesIcon /> },
];
/** Barre d'activité verticale (56px) à gauche. */
export function ActivityBar({
active,
onChange,
}: {
active: View;
onChange: (v: View) => void;
}) {
return (
<div className="flex w-14 flex-none flex-col items-center gap-1 border-r border-line-soft bg-sunken py-3">
{items.map((it) => {
const on = active === it.id;
return (
<button
key={it.id}
onClick={() => onChange(it.id)}
className={`relative flex h-10 w-10 items-center justify-center rounded-[10px] ${
on ? "text-accent" : "text-muted-3 hover:text-ink-3"
}`}
style={on ? { background: "rgba(240,161,92,.14)" } : undefined}
>
{on && (
<span className="absolute left-[-12px] top-[11px] h-[18px] w-[3px] rounded bg-accent" />
)}
{it.icon}
</button>
);
})}
<div className="flex-1" />
<button
onClick={() => onChange("settings")}
className={`relative flex h-10 w-10 items-center justify-center rounded-[10px] ${
active === "settings" ? "text-accent" : "text-muted-3 hover:text-ink-3"
}`}
style={
active === "settings"
? { background: "rgba(240,161,92,.14)" }
: undefined
}
>
{active === "settings" && (
<span className="absolute left-[-12px] top-[11px] h-[18px] w-[3px] rounded bg-accent" />
)}
<SettingsIcon />
</button>
<div className="mt-1 flex h-[30px] w-[30px] items-center justify-center rounded-full bg-[#3a332b] text-xs font-semibold text-ink-2">
M
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
/** 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>
);
/** Logo Loki : carré dégradé + losange interne. */
export const LokiMark = ({ size = 26 }: { size?: number }) => (
<div
className="flex items-center justify-center rounded-lg bg-accent-grad"
style={{ width: size, height: size }}
>
<div
className="bg-base"
style={{
width: size * 0.3,
height: size * 0.3,
borderRadius: 2,
transform: "rotate(45deg)",
}}
/>
</div>
);
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ChevronDown } from "./Icon";
/** Sélecteur de modèle Ollama (chip de la barre supérieure). */
export function ModelSelector() {
const { models, selectedModel, setSelectedModel } = 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);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="flex h-7 items-center gap-2 rounded-lg border border-line-strong bg-[#2a251f] px-[11px]"
>
<span className="h-1.5 w-1.5 rounded-full bg-accent" />
<span className="font-mono text-xs font-medium text-[#e7ddcd]">
{selectedModel || "—"}
</span>
<ChevronDown size={12} className="text-muted-2" />
</button>
{open && (
<div className="absolute right-0 top-9 z-20 w-64 rounded-lg border border-line bg-card p-1 shadow-frame">
{models.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-3">
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 rounded-md px-2 py-2 text-left hover:bg-[#252019] ${
m.name === selectedModel ? "bg-[#252019]" : ""
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
m.name === selectedModel ? "bg-accent" : "bg-muted-3"
}`}
/>
<span className="flex-1 font-mono text-xs text-ink-2">
{m.name}
</span>
{m.size_go > 0 && (
<span className="font-mono text-[10px] text-muted-3">
{m.size_go} Go
</span>
)}
</button>
))}
</div>
)}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useStore } from "../store/useStore";
import { ChevronDown, LokiMark } from "./Icon";
import { ModelSelector } from "./ModelSelector";
/** Barre supérieure : logo, fil d'Ariane, statut Ollama, sélecteur de modèle. */
export function TopBar() {
const status = useStore((s) => s.status);
const connected = status?.connected ?? false;
return (
<div className="flex h-12 flex-none items-center gap-3.5 border-b border-line-strong bg-bar px-4">
<div className="flex items-center gap-2.5">
<LokiMark />
<div className="text-[15px] font-bold tracking-tight">Loki</div>
<div className="pt-px font-mono text-[11px] text-muted-3">
agent local
</div>
</div>
<div className="h-5 w-px bg-line-strong" />
<div className="flex items-center gap-2 text-[13px] font-medium text-ink-2">
<span>Nouvelle session</span>
<ChevronDown className="text-muted-3" />
</div>
<div className="flex-1" />
{/* Statut Ollama */}
<div
className="flex h-7 items-center gap-2 rounded-lg border border-line-strong bg-card-soft px-[11px]"
title={connected ? status?.host : status?.error}
>
<span
className={`h-[7px] w-[7px] rounded-full ${
connected ? "bg-ok" : "bg-warn"
}`}
style={
connected
? { boxShadow: "0 0 0 3px rgba(134,199,154,.16)" }
: undefined
}
/>
<span className="text-xs font-medium text-ink-3">Ollama</span>
<span className="font-mono text-[11px] text-muted-3">
{connected
? status?.host.replace(/^https?:\/\//, "")
: "déconnecté"}
</span>
</div>
<ModelSelector />
</div>
);
}