Chat: rendu Markdown, blocs de code copiables, metriques de tokens, apercu HTML

Frontend
- MessageContent: rendu Markdown complet (react-markdown + remark-gfm) avec
  blocs de code dans un cadre + bouton Copier, et styles thematises (titres,
  listes, gras, liens, tableaux, citations).
- ChatPanel: affiche sous chaque reponse de l'agent la vitesse (tokens/s) et le
  nombre de jetons generes / en entree.
- PreviewPanel: iframe en sandbox="allow-scripts" pour que le JS des pages
  generees s'execute (animations, canvas) tout en gardant une origine opaque.
- Icon: ajout CopyIcon / CheckIcon.

Backend
- agent: cumule les metriques Ollama (eval_count, eval_duration,
  prompt_eval_count) et calcule les tokens/seconde, emis dans l'evenement final.
- chat: persiste les stats dans meta et les renvoie au client.
- agent_config: l'invite systeme par defaut demande un formatage Markdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-06-30 13:45:59 +02:00
1 parent aed5f8148c
commit e8e6a1446c
10 files changed
+1683 -13

No files matched your search

+1469 -6
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -11,6 +11,8 @@
"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": {
+7 -1
View File
@@ -45,13 +45,19 @@ export interface ToolCall {
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[] } | null;
meta?: { tools?: ToolCall[]; stats?: MessageStats } | null;
created_at: number;
}
+13
View File
@@ -85,6 +85,19 @@ export const DownloadIcon = ({ size = 14, className }: Props) => (
</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é dégradé + losange interne. */
export const LokiMark = ({ size = 26 }: { size?: number }) => (
<div
+132
View File
@@ -0,0 +1,132 @@
import { useState } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CheckIcon, CopyIcon } from "./Icon";
/**
* Rendu Markdown des messages de l'agent (react-markdown + GFM).
*
* Les blocs de code sont affichés dans un cadre avec en-tête (langage + bouton
* « Copier »). Les autres éléments (titres, listes, gras, tableaux, liens…)
* sont stylés pour le thème sombre « atelier » de Loki.
*/
function CodeBlock({ lang, code }: { lang: string; code: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Presse-papiers indisponible (contexte non sécurisé) : on ignore.
}
};
return (
<div className="my-2 overflow-hidden rounded-[10px] border border-line-strong bg-sunken">
<div className="flex items-center justify-between border-b border-line-soft px-3 py-1.5">
<span className="font-mono text-[10.5px] uppercase tracking-wide text-muted-3">
{lang || "code"}
</span>
<button
onClick={copy}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-2 transition-colors hover:text-ink"
>
{copied ? <CheckIcon size={13} /> : <CopyIcon size={13} />}
{copied ? "Copié" : "Copier"}
</button>
</div>
<pre className="scr m-0 overflow-auto p-3 font-mono text-[12px] leading-relaxed text-ink-2">
<code>{code}</code>
</pre>
</div>
);
}
const components: Components = {
// react-markdown emballe les blocs dans <pre><code> : on déballe le <pre>
// pour laisser notre CodeBlock (un <div>) se placer au niveau racine.
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-2 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>
);
}
+18 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { ClipIcon, LokiMark, SendIcon } from "../components/Icon";
import { ToolCard } from "../components/ToolCard";
import { MessageContent } from "../components/MessageContent";
import type { Message, ToolCall } from "../api/client";
/** Panneau central : barre de contexte, fil de conversation, composer. */
@@ -218,8 +219,10 @@ function Bubble({
</div>
)}
{(msg.content || pending) && (
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
{msg.content || (
<div className="text-sm leading-[1.65] text-ink-2">
{msg.content ? (
<MessageContent text={msg.content} />
) : (
<span className="text-muted-2">{pendingStatus || "Génération…"}</span>
)}
{pending && (
@@ -227,6 +230,19 @@ function Bubble({
)}
</div>
)}
{!pending && msg.meta?.stats && (
<div className="mt-2 flex items-center gap-2.5 font-mono text-[10.5px] text-muted-4">
{msg.meta.stats.tokens_per_sec != null && (
<span className="text-muted-3">
{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>
);
+4 -1
View File
@@ -107,7 +107,10 @@ export function PreviewPanel() {
title="aperçu"
srcDoc={previewContent}
className="h-full w-full border-0 bg-white"
sandbox="allow-same-origin"
// 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 font-mono text-[11.5px] leading-relaxed text-[#2a2018]">