Fix Ollama chat reliability

This commit is contained in:
Michael committed 2026-06-30 10:36:29 +02:00
1 parent d47f7476ed
commit e9c83e3a0d
5 files changed
+297 -78

No files matched your search

+87 -30
View File
@@ -176,15 +176,36 @@ export async function streamChat(
onToolCall: (call: ToolCall) => void;
onToolResult: (call: ToolCall) => void;
onToolConfirm: (command: string) => void;
onStatus: (msg: string) => void;
onNotice: (msg: string) => void;
onDone: (full: string) => void;
onError: (msg: string) => void;
}
): Promise<void> {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
let res: Response;
try {
res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch (err) {
handlers.onError(
err instanceof Error ? err.message : "serveur Loki injoignable"
);
return;
}
if (!res.ok) {
let message = `requête refusée (${res.status})`;
try {
const payload = await res.json();
message = payload.detail ?? payload.error ?? message;
} catch {
/* réponse non JSON */
}
handlers.onError(message);
return;
}
if (!res.body) {
handlers.onError("pas de flux de réponse");
return;
@@ -193,35 +214,71 @@ export async function streamChat(
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let terminal = false;
let failed = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
let event = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7).trim();
else if (line.startsWith("data: ")) data += line.slice(6);
const dispatch = (raw: string) => {
const block = raw.replace(/\r\n/g, "\n");
let event = "message";
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
}
if (dataLines.length === 0) return;
try {
const payload = JSON.parse(dataLines.join("\n"));
if (event === "token") handlers.onToken(payload.content);
else if (event === "status") handlers.onStatus(payload.message);
else if (event === "notice") handlers.onNotice(payload.message);
else if (event === "tool_call")
handlers.onToolCall({ ...payload, status: "running" });
else if (event === "tool_result") handlers.onToolResult(payload);
else if (event === "tool_confirm") handlers.onToolConfirm(payload.command);
else if (event === "error") {
failed = true;
handlers.onError(payload.message);
} else if (event === "done") {
terminal = true;
if (payload.error) {
if (!failed) handlers.onError(payload.error);
failed = true;
} else if (!failed) {
handlers.onDone(payload.content);
}
}
if (!data) continue;
try {
const payload = JSON.parse(data);
if (event === "token") handlers.onToken(payload.content);
else if (event === "tool_call")
handlers.onToolCall({ ...payload, status: "running" });
else if (event === "tool_result") handlers.onToolResult(payload);
else if (event === "tool_confirm") handlers.onToolConfirm(payload.command);
else if (event === "done") handlers.onDone(payload.content);
else if (event === "error") handlers.onError(payload.message);
} catch {
/* bloc partiel */
} catch {
if (!failed) {
failed = true;
handlers.onError("réponse illisible reçue du serveur");
}
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const block of events) {
if (block.trim()) dispatch(block);
}
}
buffer += decoder.decode();
if (buffer.trim()) dispatch(buffer);
} catch (err) {
if (!failed) {
failed = true;
handlers.onError(
err instanceof Error ? err.message : "connexion au chat interrompue"
);
}
}
if (!terminal && !failed) {
handlers.onError("le serveur a fermé la réponse avant sa fin");
}
}
+23 -2
View File
@@ -11,6 +11,8 @@ export function ChatPanel() {
messages,
streaming,
streamContent,
streamStatus,
streamNotice,
streamTools,
sendMessage,
currentSessionId,
@@ -101,6 +103,8 @@ export function ChatPanel() {
created_at: Date.now() / 1000,
}}
pending
pendingStatus={streamStatus}
notice={streamNotice}
/>
)}
{pendingShell && (
@@ -154,7 +158,17 @@ export function ChatPanel() {
);
}
function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
function Bubble({
msg,
pending,
pendingStatus,
notice,
}: {
msg: Message;
pending?: boolean;
pendingStatus?: string;
notice?: string | null;
}) {
const time = new Date(msg.created_at * 1000).toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
@@ -198,9 +212,16 @@ function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
<ToolCard key={i} call={t} />
))}
{notice && (
<div className="mb-2 rounded-lg border border-[#4a3a2a] bg-card-deep px-3 py-2 text-xs text-warn">
{notice}
</div>
)}
{(msg.content || pending) && (
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
{msg.content}
{msg.content || (
<span className="text-muted-2">{pendingStatus || "Génération…"}</span>
)}
{pending && (
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
)}
+67 -12
View File
@@ -34,6 +34,8 @@ interface LokiState {
messages: Message[];
streaming: boolean;
streamContent: string; // réponse de l'agent en cours de frappe
streamStatus: string;
streamNotice: string | null;
streamTools: ToolCall[]; // appels d'outils de la réponse en cours
fileTree: FileNode[];
@@ -77,6 +79,8 @@ export const useStore = create<LokiState>((set, get) => ({
messages: [],
streaming: false,
streamContent: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
fileTree: [],
previewPath: null,
@@ -154,9 +158,6 @@ export const useStore = create<LokiState>((set, get) => ({
try {
const status = await getStatus();
set({ status });
if (!get().selectedModel && status.default_model) {
set({ selectedModel: status.default_model });
}
} catch {
set({ status: { connected: false, host: "", default_model: "" } });
}
@@ -166,8 +167,14 @@ export const useStore = create<LokiState>((set, get) => ({
set({ loadingModels: true });
try {
const { models, default: def } = await getModels();
set({ models });
if (!get().selectedModel && def) set({ selectedModel: def });
const installed = new Set(models.map((model) => model.name));
const current = get().selectedModel;
const selectedModel = installed.has(current)
? current
: installed.has(def)
? def
: models[0]?.name ?? "";
set({ models, selectedModel });
} finally {
set({ loadingModels: false });
}
@@ -180,13 +187,25 @@ export const useStore = create<LokiState>((set, get) => ({
newSession: async () => {
const s = await createSession(get().selectedModel || undefined);
set({ currentSessionId: s.id, messages: [], streamContent: "" });
set({
currentSessionId: s.id,
messages: [],
streamContent: "",
streamStatus: "",
streamNotice: null,
});
await get().refreshSessions();
},
openSession: async (id) => {
const { messages } = await getSession(id);
set({ currentSessionId: id, messages, streamContent: "" });
set({
currentSessionId: id,
messages,
streamContent: "",
streamStatus: "",
streamNotice: null,
});
},
removeSession: async (id) => {
@@ -201,13 +220,37 @@ export const useStore = create<LokiState>((set, get) => ({
if (get().streaming) return;
content = content.trim();
if (!content) return;
if (!get().selectedModel) {
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: get().currentSessionId ?? "",
role: "assistant",
content: "⚠️ Aucun modèle Ollama installé ou sélectionné.",
created_at: Date.now() / 1000,
};
set({ messages: [...get().messages, errMsg] });
return;
}
// Crée une session à la volée si aucune n'est ouverte.
let sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined);
sid = s.id;
set({ currentSessionId: s.id });
try {
if (!sid) {
const s = await createSession(get().selectedModel || undefined);
sid = s.id;
set({ currentSessionId: s.id });
}
} catch (err) {
const detail = err instanceof Error ? err.message : "backend injoignable";
const errMsg: Message = {
id: `err-${Date.now()}`,
session_id: "",
role: "assistant",
content: `⚠️ Impossible de créer la session : ${detail}`,
created_at: Date.now() / 1000,
};
set({ messages: [...get().messages, errMsg] });
return;
}
// Affichage optimiste du message utilisateur.
@@ -222,6 +265,8 @@ export const useStore = create<LokiState>((set, get) => ({
messages: [...get().messages, userMsg],
streaming: true,
streamContent: "",
streamStatus: "Connexion à Ollama…",
streamNotice: null,
streamTools: [],
pendingShell: null,
});
@@ -230,6 +275,8 @@ export const useStore = create<LokiState>((set, get) => ({
{ session_id: sid, content, model: get().selectedModel || undefined },
{
onToken: (t) => set({ streamContent: get().streamContent + t }),
onStatus: (message) => set({ streamStatus: message }),
onNotice: (message) => set({ streamNotice: message }),
onToolCall: (call) =>
set({ streamTools: [...get().streamTools, call] }),
onToolResult: (call) => {
@@ -254,7 +301,13 @@ export const useStore = create<LokiState>((set, get) => ({
typeof t.args?.path === "string" &&
/\.html?$/.test(t.args.path as string)
);
set({ streaming: false, streamContent: "", streamTools: [] });
set({
streaming: false,
streamContent: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
});
// Recharge depuis la base + l'arborescence (fichiers créés par l'agent).
if (get().currentSessionId === sid) await get().openSession(sid!);
await get().refreshSessions();
@@ -272,6 +325,8 @@ export const useStore = create<LokiState>((set, get) => ({
set({
streaming: false,
streamContent: "",
streamStatus: "",
streamNotice: null,
streamTools: [],
messages: [...get().messages, errMsg],
});