diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3dfeaeb..b262d9e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -186,7 +186,9 @@ export async function streamChat( onNotice: (msg: string) => void; onDone: (full: string) => void; onError: (msg: string) => void; - } + onAbort?: () => void; + }, + signal?: AbortSignal ): Promise { let res: Response; try { @@ -194,8 +196,13 @@ export async function streamChat( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + signal, }); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") { + handlers.onAbort?.(); + return; + } const raw = err instanceof Error ? err.message : "serveur Loki injoignable"; handlers.onError( /network error|failed to fetch/i.test(raw) @@ -280,6 +287,10 @@ export async function streamChat( if (buffer.trim()) dispatch(buffer); } catch (err) { if (!failed) { + if (err instanceof DOMException && err.name === "AbortError") { + handlers.onAbort?.(); + return; + } failed = true; const raw = err instanceof Error ? err.message : "connexion interrompue"; handlers.onError( diff --git a/frontend/src/components/MessageContent.tsx b/frontend/src/components/MessageContent.tsx index e41597b..b3180a4 100644 --- a/frontend/src/components/MessageContent.tsx +++ b/frontend/src/components/MessageContent.tsx @@ -3,24 +3,44 @@ 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 copyWithTextarea(value: string) { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + 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 }) { const [copied, setCopied] = useState(false); + const [failed, setFailed] = useState(false); const copy = async () => { try { - await navigator.clipboard.writeText(code); + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(code); + } else { + copyWithTextarea(code); + } setCopied(true); + setFailed(false); setTimeout(() => setCopied(false), 1500); } 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"}
@@ -46,8 +67,6 @@ function CodeBlock({ lang, code }: { lang: string; code: string }) {
 }
 
 const components: Components = {
-  // react-markdown emballe les blocs dans 
 : on déballe le 
-  // pour laisser notre CodeBlock (un 
) se placer au niveau racine. pre: ({ children }) => <>{children}, code({ className, children }) { const text = String(children).replace(/\n$/, ""); diff --git a/frontend/src/panels/ChatPanel.tsx b/frontend/src/panels/ChatPanel.tsx index 44e6841..0b66c09 100644 --- a/frontend/src/panels/ChatPanel.tsx +++ b/frontend/src/panels/ChatPanel.tsx @@ -24,6 +24,7 @@ export function ChatPanel() { approveShell, rejectShell, sessions, + stopStreaming, } = useStore(); const activeTools = config @@ -154,12 +155,14 @@ export function ChatPanel() { )}
diff --git a/frontend/src/store/useStore.ts b/frontend/src/store/useStore.ts index bff8890..36826e8 100644 --- a/frontend/src/store/useStore.ts +++ b/frontend/src/store/useStore.ts @@ -62,8 +62,11 @@ interface LokiState { openSession: (id: string) => Promise; removeSession: (id: string) => Promise; sendMessage: (content: string) => Promise; + stopStreaming: () => void; } +let activeStreamController: AbortController | null = null; + export const useStore = create((set, get) => ({ status: null, models: [], @@ -207,6 +210,22 @@ export const useStore = create((set, get) => ({ 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) => { if (get().streaming) return; content = content.trim(); @@ -264,6 +283,9 @@ export const useStore = create((set, get) => ({ pendingShell: null, }); + const controller = new AbortController(); + activeStreamController = controller; + await streamChat( { session_id: sid, content, model: get().selectedModel || undefined }, { @@ -286,6 +308,7 @@ export const useStore = create((set, get) => ({ }, onToolConfirm: (command) => set({ pendingShell: command }), onDone: async () => { + activeStreamController = null; // Repère un fichier HTML écrit pour l'afficher automatiquement. const writtenHtml = [...get().streamTools] .reverse() @@ -311,6 +334,7 @@ export const useStore = create((set, get) => ({ if (writtenHtml) await get().openPreview(writtenHtml.args.path as string); }, onError: (msg) => { + activeStreamController = null; const errMsg: Message = { id: `err-${Date.now()}`, session_id: sid!, @@ -333,7 +357,22 @@ export const useStore = create((set, get) => ({ }); void get().refreshSessions(); }, - } + onAbort: () => { + activeStreamController = null; + set({ + streaming: false, + streamingSessionId: null, + streamContent: "", + streamThinking: "", + streamStatus: "", + streamNotice: null, + streamTools: [], + pendingShell: null, + }); + void get().refreshSessions(); + }, + }, + controller.signal ); }, }));