mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
add stop streaming and robust code copy
This commit is contained in:
1 parent
c794d58718
commit
b9bccac4ba
4 files changed
+90
-18
No files matched your search
@@ -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(
|
||||||
|
|||||||
@@ -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$/, "");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
Reference in new issue
Block a user