mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +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
+25
-1
@@ -75,6 +75,15 @@ async def run_agent(
|
||||
tool_repair_attempts = 0
|
||||
request_options = dict(options or {})
|
||||
|
||||
# Métriques cumulées sur tous les appels Ollama du tour agentique : Ollama
|
||||
# les renvoie dans le chunk final (done=true) de chaque génération.
|
||||
stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0}
|
||||
|
||||
def _accumulate(chunk: dict) -> None:
|
||||
stats["eval_count"] += chunk.get("eval_count") or 0
|
||||
stats["eval_duration"] += chunk.get("eval_duration") or 0
|
||||
stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0
|
||||
|
||||
try:
|
||||
for _ in range(MAX_ITERATIONS):
|
||||
content_buf = ""
|
||||
@@ -107,6 +116,7 @@ async def run_agent(
|
||||
if msg.get("tool_calls"):
|
||||
tool_calls.extend(msg["tool_calls"])
|
||||
if chunk.get("done"):
|
||||
_accumulate(chunk)
|
||||
break
|
||||
break
|
||||
except OllamaError as exc:
|
||||
@@ -249,6 +259,7 @@ async def run_agent(
|
||||
final_chunk += tok
|
||||
yield {"type": "token", "content": tok}
|
||||
if chunk.get("done"):
|
||||
_accumulate(chunk)
|
||||
break
|
||||
if final_chunk.strip():
|
||||
text_parts.append(final_chunk.strip())
|
||||
@@ -276,4 +287,17 @@ async def run_agent(
|
||||
}
|
||||
return
|
||||
|
||||
yield {"type": "final", "content": final_content, "tools": collected}
|
||||
eval_secs = stats["eval_duration"] / 1e9
|
||||
final_stats = {
|
||||
"eval_count": stats["eval_count"],
|
||||
"prompt_eval_count": stats["prompt_eval_count"],
|
||||
"tokens_per_sec": (
|
||||
round(stats["eval_count"] / eval_secs, 1) if eval_secs > 0 else None
|
||||
),
|
||||
}
|
||||
yield {
|
||||
"type": "final",
|
||||
"content": final_content,
|
||||
"tools": collected,
|
||||
"stats": final_stats,
|
||||
}
|
||||
@@ -18,7 +18,10 @@ DEFAULT_SYSTEM_PROMPT = (
|
||||
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
|
||||
"concise en français. Pour un fichier long, appelle write_file en plusieurs "
|
||||
"morceaux (overwrite puis append) afin de toujours produire un JSON valide. "
|
||||
"Après avoir écrit un fichier, propose un aperçu."
|
||||
"Après avoir écrit un fichier, propose un aperçu. "
|
||||
"Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les "
|
||||
"points clés, tableaux si pertinent, et surtout des blocs de code avec le "
|
||||
"langage indiqué (```python, ```html…) pour tout extrait de code ou commande."
|
||||
)
|
||||
|
||||
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
|
||||
|
||||
@@ -58,6 +58,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
yield _sse("start", {"model": model})
|
||||
final_content = ""
|
||||
tools_meta: list[dict] = []
|
||||
stats_meta: dict | None = None
|
||||
error_message = ""
|
||||
|
||||
queue: asyncio.Queue[dict | None] = asyncio.Queue()
|
||||
@@ -113,6 +114,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
elif etype == "final":
|
||||
final_content = ev["content"]
|
||||
tools_meta = ev["tools"]
|
||||
stats_meta = ev.get("stats")
|
||||
finally:
|
||||
if not producer.done():
|
||||
producer.cancel()
|
||||
@@ -120,18 +122,24 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
await producer
|
||||
|
||||
if final_content or tools_meta:
|
||||
meta: dict = {}
|
||||
if tools_meta:
|
||||
meta["tools"] = tools_meta
|
||||
if stats_meta:
|
||||
meta["stats"] = stats_meta
|
||||
db.add_message(
|
||||
req.session_id,
|
||||
"assistant",
|
||||
final_content,
|
||||
model,
|
||||
meta={"tools": tools_meta} if tools_meta else None,
|
||||
meta=meta or None,
|
||||
)
|
||||
yield _sse(
|
||||
"done",
|
||||
{
|
||||
"content": final_content,
|
||||
"tools": tools_meta,
|
||||
"stats": stats_meta,
|
||||
"error": error_message or None,
|
||||
},
|
||||
)
|
||||
|
||||
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