mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 22:18:13 +02:00
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:
87 files changed
-18730
No files matched your search
@@ -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>
|
||||
Generated
-4273
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user