add stop streaming and robust code copy

This commit is contained in:
Michael committed 2026-06-30 20:12:20 +02:00
1 parent c794d58718
commit b9bccac4ba
4 files changed
+90 -18

No files matched your search

+12 -1
View File
@@ -186,7 +186,9 @@ export async function streamChat(
onNotice: (msg: string) => void; onNotice: (msg: string) => void;
onDone: (full: string) => void; onDone: (full: string) => void;
onError: (msg: string) => void; onError: (msg: string) => void;
} onAbort?: () => void;
},
signal?: AbortSignal
): Promise<void> { ): Promise<void> {
let res: Response; let res: Response;
try { try {
@@ -194,8 +196,13 @@ export async function streamChat(
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
const raw = err instanceof Error ? err.message : "serveur Loki injoignable"; const raw = err instanceof Error ? err.message : "serveur Loki injoignable";
handlers.onError( handlers.onError(
/network error|failed to fetch/i.test(raw) /network error|failed to fetch/i.test(raw)
@@ -280,6 +287,10 @@ export async function streamChat(
if (buffer.trim()) dispatch(buffer); if (buffer.trim()) dispatch(buffer);
} catch (err) { } catch (err) {
if (!failed) { if (!failed) {
if (err instanceof DOMException && err.name === "AbortError") {
handlers.onAbort?.();
return;
}
failed = true; failed = true;
const raw = err instanceof Error ? err.message : "connexion interrompue"; const raw = err instanceof Error ? err.message : "connexion interrompue";
handlers.onError( handlers.onError(
+31 -12
View File
@@ -3,24 +3,44 @@ import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { CheckIcon, CopyIcon } from "./Icon"; import { CheckIcon, CopyIcon } from "./Icon";
/** function copyWithTextarea(value: string) {
* Rendu Markdown des messages de l'agent (react-markdown + GFM). const textarea = document.createElement("textarea");
* textarea.value = value;
* Les blocs de code sont affichés dans un cadre avec en-tête (langage + bouton textarea.setAttribute("readonly", "");
* « Copier »). Les autres éléments (titres, listes, gras, tableaux, liens…) textarea.style.position = "fixed";
* sont stylés pour le thème sombre « atelier » de Loki. textarea.style.left = "-9999px";
*/ textarea.style.top = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
textarea.remove();
if (!ok) throw new Error("copy failed");
}
function CodeBlock({ lang, code }: { lang: string; code: string }) { function CodeBlock({ lang, code }: { lang: string; code: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const copy = async () => { const copy = async () => {
try { try {
await navigator.clipboard.writeText(code); if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(code);
} else {
copyWithTextarea(code);
}
setCopied(true); setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} catch { } catch {
// Presse-papiers indisponible (contexte non sécurisé) : on ignore. try {
copyWithTextarea(code);
setCopied(true);
setFailed(false);
setTimeout(() => setCopied(false), 1500);
} catch {
setFailed(true);
setTimeout(() => setFailed(false), 1800);
}
} }
}; };
@@ -31,11 +51,12 @@ function CodeBlock({ lang, code }: { lang: string; code: string }) {
{lang || "code"} {lang || "code"}
</span> </span>
<button <button
type="button"
onClick={copy} onClick={copy}
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-on-dark-2 transition-colors hover:text-white" className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-on-dark-2 transition-colors hover:text-white"
> >
{copied ? <CheckIcon size={13} /> : <CopyIcon size={13} />} {copied ? <CheckIcon size={13} /> : <CopyIcon size={13} />}
{copied ? "Copié" : "Copier"} {failed ? "Erreur" : copied ? "Copié" : "Copier"}
</button> </button>
</div> </div>
<pre className="scr m-0 overflow-auto p-3 font-mono text-[12px] leading-relaxed text-on-dark"> <pre className="scr m-0 overflow-auto p-3 font-mono text-[12px] leading-relaxed text-on-dark">
@@ -46,8 +67,6 @@ function CodeBlock({ lang, code }: { lang: string; code: string }) {
} }
const components: Components = { 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}</>, pre: ({ children }) => <>{children}</>,
code({ className, children }) { code({ className, children }) {
const text = String(children).replace(/\n$/, ""); const text = String(children).replace(/\n$/, "");
+7 -4
View File
@@ -24,6 +24,7 @@ export function ChatPanel() {
approveShell, approveShell,
rejectShell, rejectShell,
sessions, sessions,
stopStreaming,
} = useStore(); } = useStore();
const activeTools = config const activeTools = config
@@ -154,12 +155,14 @@ export function ChatPanel() {
</> </>
)} )}
<button <button
onClick={submit} onClick={streaming ? stopStreaming : submit}
disabled={streaming || !draft.trim()} disabled={!streaming && !draft.trim()}
className="flex h-[38px] items-center gap-1.5 border-[3px] border-line bg-card-deep px-4 text-[14px] text-white shadow-hard-accent disabled:opacity-40" className={`flex h-[38px] items-center gap-1.5 border-[3px] border-line px-4 text-[14px] text-white shadow-hard-accent disabled:opacity-40 ${
streaming ? "bg-warn" : "bg-card-deep"
}`}
style={{ borderRadius: 7 }} style={{ borderRadius: 7 }}
> >
{streaming ? "…" : "ENVOYER"} {streaming ? "ARRÊTER" : "ENVOYER"}
{!streaming && <SendIcon />} {!streaming && <SendIcon />}
</button> </button>
</div> </div>
+40 -1
View File
@@ -62,8 +62,11 @@ interface LokiState {
openSession: (id: string) => Promise<void>; openSession: (id: string) => Promise<void>;
removeSession: (id: string) => Promise<void>; removeSession: (id: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>; sendMessage: (content: string) => Promise<void>;
stopStreaming: () => void;
} }
let activeStreamController: AbortController | null = null;
export const useStore = create<LokiState>((set, get) => ({ export const useStore = create<LokiState>((set, get) => ({
status: null, status: null,
models: [], models: [],
@@ -207,6 +210,22 @@ export const useStore = create<LokiState>((set, get) => ({
await get().refreshSessions(); await get().refreshSessions();
}, },
stopStreaming: () => {
activeStreamController?.abort();
activeStreamController = null;
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
pendingShell: null,
});
void get().refreshSessions();
},
sendMessage: async (content) => { sendMessage: async (content) => {
if (get().streaming) return; if (get().streaming) return;
content = content.trim(); content = content.trim();
@@ -264,6 +283,9 @@ export const useStore = create<LokiState>((set, get) => ({
pendingShell: null, pendingShell: null,
}); });
const controller = new AbortController();
activeStreamController = controller;
await streamChat( await streamChat(
{ session_id: sid, content, model: get().selectedModel || undefined }, { session_id: sid, content, model: get().selectedModel || undefined },
{ {
@@ -286,6 +308,7 @@ export const useStore = create<LokiState>((set, get) => ({
}, },
onToolConfirm: (command) => set({ pendingShell: command }), onToolConfirm: (command) => set({ pendingShell: command }),
onDone: async () => { onDone: async () => {
activeStreamController = null;
// Repère un fichier HTML écrit pour l'afficher automatiquement. // Repère un fichier HTML écrit pour l'afficher automatiquement.
const writtenHtml = [...get().streamTools] const writtenHtml = [...get().streamTools]
.reverse() .reverse()
@@ -311,6 +334,7 @@ export const useStore = create<LokiState>((set, get) => ({
if (writtenHtml) await get().openPreview(writtenHtml.args.path as string); if (writtenHtml) await get().openPreview(writtenHtml.args.path as string);
}, },
onError: (msg) => { onError: (msg) => {
activeStreamController = null;
const errMsg: Message = { const errMsg: Message = {
id: `err-${Date.now()}`, id: `err-${Date.now()}`,
session_id: sid!, session_id: sid!,
@@ -333,7 +357,22 @@ export const useStore = create<LokiState>((set, get) => ({
}); });
void get().refreshSessions(); void get().refreshSessions();
}, },
} onAbort: () => {
activeStreamController = null;
set({
streaming: false,
streamingSessionId: null,
streamContent: "",
streamThinking: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
pendingShell: null,
});
void get().refreshSessions();
},
},
controller.signal
); );
}, },
})); }));