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 */} -
+
-
+