From b4fc8cee3d80f068fe785fecd605dd3e344cc0f0 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 30 Jun 2026 14:21:05 +0200 Subject: [PATCH] Theme neo-brutaliste + panneau raisonnement + repli sans outils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI : reskin complet vers un theme neo-brutaliste clair (bordures noires 3px, ombres dures, accent orange, polices Press Start 2P + DM Mono). - tailwind.config / index.css / index.html : palette, polices, ombres, scrollbars - TopBar, ActivityBar, LeftPanel, ChatPanel, PreviewPanel, SettingsView, ToolCard, ModelSelector, Icon : restyle complet Chat : panneau « Raisonnement » repliable et redimensionnable affichant le thinking de l'agent en direct (diffuse depuis le backend, persiste dans meta). Robustesse Ollama : les echecs de generation de parseur d'outils (« Unable to generate parser for this template ») declenchent desormais le repli automatique en conversation simple sans outils, au lieu d'un 400. Co-Authored-By: Claude Opus 4.8 --- backend/app/agent.py | 23 ++- backend/app/routes/chat.py | 5 + frontend/index.html | 2 +- frontend/src/api/client.ts | 4 +- frontend/src/components/ActivityBar.tsx | 52 ++--- frontend/src/components/Icon.tsx | 22 +-- frontend/src/components/ModelSelector.tsx | 33 ++-- frontend/src/components/ToolCard.tsx | 25 +-- frontend/src/components/TopBar.tsx | 33 ++-- frontend/src/index.css | 24 ++- frontend/src/panels/ChatPanel.tsx | 163 ++++++++++------ frontend/src/panels/LeftPanel.tsx | 104 +++++----- frontend/src/panels/PreviewPanel.tsx | 107 +++++----- frontend/src/panels/SettingsView.tsx | 225 +++++++++++----------- frontend/src/store/useStore.ts | 6 + frontend/tailwind.config.ts | 79 ++++---- 16 files changed, 488 insertions(+), 419 deletions(-) diff --git a/backend/app/agent.py b/backend/app/agent.py index d01c807..a2d1ccb 100644 --- a/backend/app/agent.py +++ b/backend/app/agent.py @@ -26,9 +26,22 @@ MAX_TOOL_REPAIR_ATTEMPTS = 2 def _tools_not_supported(exc: OllamaError) -> bool: - """Détecte l'erreur Ollama renvoyée par un modèle sans function calling.""" + """Détecte un modèle incapable de function calling. + + Deux cas : + - Ollama refuse explicitement (« does not support tools ») ; + - Ollama n'arrive pas à dériver un parseur d'appels d'outils du template + du modèle (« Unable to generate parser for this template ») — fréquent + sur des modèles exotiques dont le template Jinja lève une exception. + Dans les deux cas, on retombe sur une conversation simple, sans outils. + """ message = str(exc).lower() - return "does not support tools" in message or "does not support tool" in message + return ( + "does not support tools" in message + or "does not support tool" in message + or "unable to generate parser for this template" in message + or "automatic parser generation failed" in message + ) def _invalid_tool_arguments(exc: OllamaError) -> bool: @@ -70,6 +83,7 @@ async def run_agent( tools = None collected: list[dict] = [] text_parts: list[str] = [] + thinking_parts: list[str] = [] active_tools = tools tool_fallback_used = False tool_repair_attempts = 0 @@ -107,6 +121,9 @@ async def run_agent( thinking = msg.get("thinking", "") if thinking: thinking_buf += thinking + # Diffuse le raisonnement en direct pour l'afficher + # dans le panneau repliable du chat. + yield {"type": "thinking", "content": thinking} if not thinking_status_sent: thinking_status_sent = True yield {"type": "status", "message": "Réflexion…"} @@ -179,6 +196,7 @@ async def run_agent( assistant_turn: dict = {"role": "assistant", "content": content_buf} if thinking_buf: assistant_turn["thinking"] = thinking_buf + thinking_parts.append(thinking_buf) if tool_calls: assistant_turn["tool_calls"] = tool_calls convo.append(assistant_turn) @@ -300,4 +318,5 @@ async def run_agent( "content": final_content, "tools": collected, "stats": final_stats, + "thinking": "\n\n".join(thinking_parts).strip(), } diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index 9591e02..684a43b 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -59,6 +59,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: final_content = "" tools_meta: list[dict] = [] stats_meta: dict | None = None + thinking_meta = "" error_message = "" queue: asyncio.Queue[dict | None] = asyncio.Queue() @@ -101,6 +102,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: etype = ev.pop("type") if etype in ( "token", + "thinking", "status", "notice", "tool_call", @@ -115,6 +117,7 @@ async def chat(req: ChatRequest) -> StreamingResponse: final_content = ev["content"] tools_meta = ev["tools"] stats_meta = ev.get("stats") + thinking_meta = ev.get("thinking", "") finally: if not producer.done(): producer.cancel() @@ -127,6 +130,8 @@ async def chat(req: ChatRequest) -> StreamingResponse: meta["tools"] = tools_meta if stats_meta: meta["stats"] = stats_meta + if thinking_meta: + meta["thinking"] = thinking_meta db.add_message( req.session_id, "assistant", diff --git a/frontend/index.html b/frontend/index.html index d07fdf3..5a45fed 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ Loki — Agent local diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index e74ae57..a7e1b37 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -57,7 +57,7 @@ export interface Message { role: "user" | "assistant"; content: string; model?: string; - meta?: { tools?: ToolCall[]; stats?: MessageStats } | null; + meta?: { tools?: ToolCall[]; stats?: MessageStats; thinking?: string } | null; created_at: number; } @@ -156,6 +156,7 @@ export async function streamChat( body: { session_id: string; content: string; model?: string }, handlers: { onToken: (t: string) => void; + onThinking: (t: string) => void; onToolCall: (call: ToolCall) => void; onToolResult: (call: ToolCall) => void; onToolConfirm: (command: string) => void; @@ -215,6 +216,7 @@ export async function streamChat( try { const payload = JSON.parse(dataLines.join("\n")); if (event === "token") handlers.onToken(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") diff --git a/frontend/src/components/ActivityBar.tsx b/frontend/src/components/ActivityBar.tsx index aa7c08d..dce3ee2 100644 --- a/frontend/src/components/ActivityBar.tsx +++ b/frontend/src/components/ActivityBar.tsx @@ -15,7 +15,14 @@ const items: { id: View; icon: React.ReactNode }[] = [ { id: "tools", icon: }, ]; -/** Barre d'activité verticale (56px) à gauche. */ +const btn = (on: boolean) => + `flex h-10 w-10 items-center justify-center border-[3px] ${ + on + ? "border-white bg-accent text-white shadow-accent-soft" + : "border-chrome-3 text-on-dark-2 hover:text-white" + }`; + +/** Barre d'activité verticale (60px) sombre à gauche. */ export function ActivityBar({ active, onChange, @@ -24,46 +31,27 @@ export function ActivityBar({ onChange: (v: View) => void; }) { return ( -
- {items.map((it) => { - const on = active === it.id; - return ( - - ); - })} +
+ {items.map((it) => ( + + ))}
-
+
M
diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 3aa7602..976d966 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -98,20 +98,20 @@ export const CheckIcon = ({ size = 14, className }: Props) => ( ); -/** Logo Loki : carré dégradé + losange interne. */ -export const LokiMark = ({ size = 26 }: { size?: number }) => ( +/** Logo Loki : carré orange à bordure blanche + carré interne (néo-brutaliste). */ +export const LokiMark = ({ size = 30 }: { size?: number }) => (
); diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 43d3f9a..ffefc43 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useStore } from "../store/useStore"; import { ChevronDown } from "./Icon"; -/** Sélecteur de modèle Ollama (chip de la barre supérieure). */ +/** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */ export function ModelSelector() { const { models, selectedModel, setSelectedModel } = useStore(); const [open, setOpen] = useState(false); @@ -20,19 +20,18 @@ export function ModelSelector() {
{open && ( -
+
{models.length === 0 && ( -
+
Aucun modèle installé
)} @@ -43,22 +42,18 @@ export function ModelSelector() { setSelectedModel(m.name); setOpen(false); }} - className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-left hover:bg-[#252019] ${ - m.name === selectedModel ? "bg-[#252019]" : "" + className={`flex w-full items-center gap-2 px-2 py-2 text-left hover:bg-base ${ + m.name === selectedModel ? "bg-base" : "" }`} > - - {m.name} - + {m.name} {m.size_go > 0 && ( - - {m.size_go} Go - + {m.size_go} Go )} ))} diff --git a/frontend/src/components/ToolCard.tsx b/frontend/src/components/ToolCard.tsx index de0f77f..fa841f9 100644 --- a/frontend/src/components/ToolCard.tsx +++ b/frontend/src/components/ToolCard.tsx @@ -18,30 +18,31 @@ export function ToolCard({ call }: { call: ToolCall }) { ? "(…)" : "()"; + const write = call.name === "write_file"; + return ( -
-
+
+
- - {call.name} - - {argPreview} + {call.name} + {argPreview}
{running ? ( - + en cours ) : pending ? ( - ⏸ à valider + ⏸ à valider ) : ( @@ -50,7 +51,7 @@ export function ToolCard({ call }: { call: ToolCall }) { )}
{call.summary && !running && ( -
+
→ {call.summary}
)} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index edf2abd..3830b69 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -2,50 +2,41 @@ import { useStore } from "../store/useStore"; import { ChevronDown, LokiMark } from "./Icon"; import { ModelSelector } from "./ModelSelector"; -/** Barre supérieure : logo, fil d'Ariane, statut Ollama, sélecteur de modèle. */ +/** Barre supérieure sombre : logo, fil d'Ariane, statut Ollama, sélecteur. */ export function TopBar() { const status = useStore((s) => s.status); const connected = status?.connected ?? false; return ( -
+
-
Loki
-
- agent local -
+
LOKI
+
agent local
-
+
-
+
Nouvelle session - +
{/* Statut Ollama */}
- Ollama - - {connected - ? status?.host.replace(/^https?:\/\//, "") - : "déconnecté"} + Ollama + + {connected ? status?.host.replace(/^https?:\/\//, "") : "déconnecté"}
diff --git a/frontend/src/index.css b/frontend/src/index.css index 3fc2584..c124437 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -10,21 +10,27 @@ body, } body { - background: #1a1714; - color: #efe9df; - font-family: "Hanken Grotesk", system-ui, sans-serif; + background: #ececef; + color: #18181b; + font-family: "DM Mono", ui-monospace, monospace; + letter-spacing: -0.01em; -webkit-font-smoothing: antialiased; } -/* Barres de défilement fidèles au visuel */ +/* Police pixel pour les libellés (utilitaire .font-pixel via Tailwind aussi) */ +.font-pixel { + font-family: "Press Start 2P", ui-monospace, monospace; +} + +/* Barres de défilement néo-brutalistes */ .scr::-webkit-scrollbar { - width: 9px; - height: 9px; + width: 12px; + height: 12px; } .scr::-webkit-scrollbar-thumb { - background: #3a342c; - border-radius: 6px; + background: #18181b; + border: 2px solid #ececef; } .scr::-webkit-scrollbar-track { - background: transparent; + background: #e4e4e7; } diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index f8f41bc..7171917 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useStore } from "../store/useStore"; -import { ClipIcon, LokiMark, SendIcon } from "../components/Icon"; +import { ChevronDown, ClipIcon, LokiMark, SendIcon } from "../components/Icon"; import { ToolCard } from "../components/ToolCard"; import { MessageContent } from "../components/MessageContent"; import type { Message, ToolCall } from "../api/client"; @@ -12,6 +12,7 @@ export function ChatPanel() { messages, streaming, streamContent, + streamThinking, streamStatus, streamNotice, streamTools, @@ -33,7 +34,7 @@ export function ChatPanel() { // Auto-scroll vers le bas à chaque token / message. useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [messages, streamContent, streamTools]); + }, [messages, streamContent, streamThinking, streamTools]); const submit = () => { if (!draft.trim() || streaming) return; @@ -53,21 +54,18 @@ export function ChatPanel() { return (
{/* Barre de contexte */} -
- Invite système +
+ ⚑ Invite système - - {activeTools} outil{activeTools > 1 ? "s" : ""} actif - {activeTools > 1 ? "s" : ""} + + {activeTools} outil{activeTools > 1 ? "s" : ""} - Température{" "} - - {config ? config.temperature.toFixed(2) : "—"} - + Temp{" "} + {config ? config.temperature.toFixed(1) : "—"}
- + {currentSessionId ? `${messages.length} message${messages.length > 1 ? "s" : ""}` : "aucune session"} @@ -78,17 +76,15 @@ export function ChatPanel() {
{empty ? (
- -
- Prêt à travailler. -
-
- Décris une tâche à l'agent. Les outils fichiers et l'aperçu en - direct arrivent à la prochaine étape. + +
PRÊT À TRAVAILLER
+
+ Décris une tâche à l'agent. Outils fichiers et aperçu en direct + disponibles.
) : ( -
+
{messages.map((m) => ( ))} @@ -106,6 +102,7 @@ export function ChatPanel() { pending pendingStatus={streamStatus} notice={streamNotice} + thinking={streamThinking} /> )} {pendingShell && ( @@ -120,35 +117,34 @@ export function ChatPanel() {
{/* Composer */} -
+
-
+