mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
aed5f8148c
commit
e8e6a1446c
10 files changed
+1683
-13
No files matched your search
Generated
+1469
-6
File diff suppressed because it is too large.
Load diff
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in new issue
Block a user