From 8e64c0c963c7d223f1f55e132561a58d3506a992 Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Thu, 24 Sep 2026 10:41:57 +0200 Subject: [PATCH] ui fixes --- frontend/src/App.tsx | 82 ++++++++++++++++++++++------ frontend/src/lib/i18n.ts | 6 +++ frontend/src/lib/localPrefs.ts | 99 ++++++++++++++++++++++++++++++++++ prompt_v2 | 17 ++++++ 4 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 frontend/src/lib/localPrefs.ts create mode 100644 prompt_v2 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54ede88..9f3bc19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { ReviewPage } from "./components/ReviewPage.tsx"; import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx"; import { Toasts } from "./components/Toasts.tsx"; import { pushToast } from "./lib/toasts.ts"; +import { clearFailedEntry, getFailedEntry, getSavedModel, pickModel, setFailedEntry, setSavedModel } from "./lib/localPrefs.ts"; import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts"; import { useT } from "./lib/i18n.ts"; @@ -126,6 +127,14 @@ export function App() { // its conversation, and how many times this prompt was already retried. const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null); const [retryCount, setRetryCount] = useState(0); + // Conversation currently receiving a streamed reply (null when idle). + // Streaming / thinking / sending UI is rendered ONLY there — every other + // conversation stays clean and selectable while a reply lands. + const [sendingFor, setSendingFor] = useState(null); + function setFlight(id: string | null): void { + sendingForRef.current = id; + setSendingFor(id); + } const { t } = useT(); // Public routes: no auth needed. IDs are constant for the page lifetime. @@ -199,7 +208,7 @@ export function App() { setReportsEnabled(t.reportsEnabled ?? true); setUsernameChangeEnabled(t.usernameChangeEnabled ?? true); setModels(m.models); - if (m.models.length > 0) setModel(m.models[0]!.id); + if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(res.user.username))); setConvs(c.conversations); setConvsReady(true); // Deep link: /chat/ opens that conversation directly. @@ -290,9 +299,24 @@ export function App() { // stream lands — the server list is still empty mid-flight, so a refetch // here would blank the fresh exchange (displayed as a recreated chat). if (sendingForRef.current === activeId) return; + const id = activeId; api - .getConv(activeId) - .then((res) => setMessages(res.messages)) + .getConv(id) + .then((res) => { + if (activeIdRef.current !== id || sendingForRef.current === id) return; + setMessages(res.messages); + // A model error is persisted browser-side (never server-side, so + // history stays clean): re-create the trailing error bubble + retry + // state after a refresh. Stale entries (conversation moved on) drop. + const entry = getFailedEntry(id); + if (entry && entry.index === res.messages.length) { + setMessages([...res.messages, { role: "assistant", content: t("attach.chatError") }]); + setFailed({ index: entry.index, convId: id }); + setRetryCount(entry.retryCount); + } else if (entry) { + clearFailedEntry(id); + } + }) .catch(() => setMessages([])); }, [activeId]); @@ -327,7 +351,7 @@ export function App() { .models() .then((m) => { setModels(m.models); - if (m.models.length > 0) setModel(m.models[0]!.id); + if (m.models.length > 0) setModel(pickModel(m.models, getSavedModel(u.username))); }) .catch(() => {}); api @@ -380,7 +404,14 @@ export function App() { } async function send(text: string) { - if (sending || !model || archivedConv) return; + if (sending || !model || archivedConv) { + // Single flight: another conversation is streaming — say so instead + // of silently swallowing the submit. + if (sending && sendingForRef.current && sendingForRef.current !== activeIdRef.current) { + pushToast(t("chat.busyOther"), { icon: "model" }); + } + return; + } let convId = activeId; if (!convId) { try { @@ -390,16 +421,16 @@ export function App() { // render): the URL is still bare /chat while the selection already // points at the new id, and sendingForRef shields both until the // stream lands. Then refresh the list and move the URL to /chat/. - sendingForRef.current = convId; + setFlight(convId); setActiveId(convId); await refreshConvs(convId); navigate(`/chat/${convId}`); } catch (err) { - sendingForRef.current = null; + setFlight(null); return; } } else { - sendingForRef.current = convId; + setFlight(convId); } const userMsg: ChatMessage = { role: "user", content: text }; // Attachments travel as reviewed text blocks appended to the message. @@ -412,7 +443,11 @@ export function App() { ); if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n"); - const history = [...messages, userMsg]; + // A trailing error bubble is display-only: never send the fake error + // text to the model, whether it was just created or restored. + const cleanBase = + failed && failed.convId === convId ? messages.filter((_, i) => i !== failed.index) : messages; + const history = [...cleanBase, userMsg]; setMessages(history); setAttachments([]); setFailed(null); @@ -428,6 +463,7 @@ export function App() { setMessages([...history, { role: "assistant", content: full }]); } setStreaming(""); + clearFailedEntry(convId!); // Explicit select: never let the post-stream refresh drift the // selection (or the URL) away from this conversation. await refreshConvs(convId!); @@ -440,9 +476,12 @@ export function App() { ]); setFailed({ index: history.length, convId: convId! }); } + // Persist the failure browser-side so the bubble + retry survive a + // refresh (nothing fake is written server-side). + setFailedEntry(convId!, { index: history.length, retryCount: 0, at: Date.now() }); setStreaming(""); } finally { - if (sendingForRef.current === convId) sendingForRef.current = null; + if (sendingForRef.current === convId) setFlight(null); setSending(false); } } @@ -456,10 +495,11 @@ export function App() { if (sending || !failed || retryCount >= MAX_RETRIES || !model || archivedConv) return; const history = messages.slice(0, failed.index); const convId = failed.convId; - sendingForRef.current = convId; + const nextCount = retryCount + 1; + setFlight(convId); setMessages(history); setFailed(null); - setRetryCount((c) => c + 1); + setRetryCount(nextCount); setSending(true); setStreaming(""); try { @@ -468,6 +508,7 @@ export function App() { setMessages([...history, { role: "assistant", content: full }]); } setRetryCount(0); + clearFailedEntry(convId); await refreshConvs(convId); if (activeIdRef.current === convId) navigate(`/chat/${convId}`); } catch { @@ -478,9 +519,10 @@ export function App() { ]); setFailed({ index: history.length, convId }); } + setFailedEntry(convId, { index: history.length, retryCount: nextCount, at: Date.now() }); setStreaming(""); } finally { - if (sendingForRef.current === convId) sendingForRef.current = null; + if (sendingForRef.current === convId) setFlight(null); setSending(false); } } @@ -552,6 +594,7 @@ export function App() { async function removeConv(c: Conversation) { try { await api.deleteConv(c.id); + clearFailedEntry(c.id); if (activeId === c.id) { setActiveId(null); setMessages([]); @@ -637,7 +680,9 @@ export function App() { const m = await api.models(); setModels(m.models); setModel((cur) => - cur && m.models.some((x) => x.id === cur) ? cur : (m.models[0]?.id ?? ""), + cur && m.models.some((x) => x.id === cur) + ? cur + : pickModel(m.models, user ? getSavedModel(user.username) : null), ); } catch { // ignore (session errors reload the page via api layer) @@ -715,12 +760,15 @@ export function App() { user={user} conv={active} messages={messages} - streaming={streaming} - sending={sending} + // Streaming / thinking / sending UI lives only in the conversation + // being generated for — anywhere else renders clean and stays usable. + streaming={sendingFor === activeId ? streaming : ""} + sending={sending && sendingFor === activeId} models={models} model={model} onModelChange={(id) => { setModel(id); + setSavedModel(user.username, id); pushToast(t("toast.modelChanged", { label: models.find((m) => m.id === id)?.label ?? id }), { icon: "model", tag: "model", @@ -741,7 +789,7 @@ export function App() { setAttachments((prev) => prev.filter((a) => a.id !== id)) } onPickFile={handlePickFile} - failedIndex={failed?.index ?? null} + failedIndex={failed && failed.convId === activeId ? failed.index : null} retryCount={retryCount} maxRetries={MAX_RETRIES} onRetry={readOnly ? undefined : retryFailed} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 2003c57..86b361c 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -109,6 +109,7 @@ const en = { "chat.greet": "How can I help, {name}?", "chat.greetSub": "Pick a model, then type below. Markdown is supported.", "chat.thinking": "Thinking…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Message KisAssistant…", "chat.send": "Send", "chat.attachTip": "Attach a file", @@ -690,6 +691,7 @@ const es: Dict = { "chat.greet": "¿En qué te ayudo, {name}?", "chat.greetSub": "Elige un modelo y escribe abajo. Markdown soportado.", "chat.thinking": "Pensando…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Escribe a KisAssistant…", "chat.send": "Enviar", "chat.attachTip": "Adjuntar un archivo", @@ -1262,6 +1264,7 @@ const fr: Dict = { "chat.greetSub": "Choisissez un modèle, puis écrivez ci-dessous. Markdown pris en charge.", "chat.thinking": "Réflexion…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Écrivez à KisAssistant…", "chat.send": "Envoyer", "chat.attachTip": "Joindre un fichier", @@ -1832,6 +1835,7 @@ const ru: Dict = { "chat.greet": "Чем помочь, {name}?", "chat.greetSub": "Выберите модель и пишите ниже. Поддерживается Markdown.", "chat.thinking": "Думаю…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Сообщение KisAssistant…", "chat.send": "Отправить", "chat.attachTip": "Прикрепить файл", @@ -2390,6 +2394,7 @@ const it: Dict = { "chat.greet": "Come posso aiutare, {name}?", "chat.greetSub": "Scegli un modello e scrivi qui sotto. Markdown supportato.", "chat.thinking": "Penso…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Scrivi a KisAssistant…", "chat.send": "Invia", "chat.attachTip": "Allega un file", @@ -2954,6 +2959,7 @@ const pt: Dict = { "chat.greet": "Como posso ajudar, {name}?", "chat.greetSub": "Escolhe um modelo e escreve abaixo. Markdown suportado.", "chat.thinking": "A pensar…", + "chat.busyOther": "A reply is still generating in another chat — please wait.", "chat.placeholder": "Escreve ao KisAssistant…", "chat.send": "Enviar", "chat.attachTip": "Anexar ficheiro", diff --git a/frontend/src/lib/localPrefs.ts b/frontend/src/lib/localPrefs.ts new file mode 100644 index 0000000..de6c871 --- /dev/null +++ b/frontend/src/lib/localPrefs.ts @@ -0,0 +1,99 @@ +/** + * Browser-local UI persistence (no server round-trip, per browser profile). + * + * - Selected model per account (`ka_model:`): restored on boot when + * still offered, otherwise the first available model wins. + * - Failed model attempts (`ka_failed_v1`): the error bubble + retry state + * survive a refresh. The fake error text is NEVER stored server-side, so + * model history stays clean — the bubble is re-created locally on load and + * filtered out of every outgoing payload. + */ + +export interface FailedEntry { + /** Position of the error bubble in the message list (trailing). */ + index: number; + retryCount: number; + at: number; +} + +const FAILED_KEY = "ka_failed_v1"; +const FAILED_MAX_AGE_MS = 30 * 24 * 3600_000; + +function readFailedMap(): Record { + try { + const raw = localStorage.getItem(FAILED_KEY); + if (!raw) return {}; + const obj = JSON.parse(raw) as Record; + const out: Record = {}; + const now = Date.now(); + for (const [k, v] of Object.entries(obj ?? {})) { + const e = v as FailedEntry; + if ( + v !== null && + typeof v === "object" && + Number.isInteger(e.index) && + e.index >= 0 && + typeof e.retryCount === "number" && + typeof e.at === "number" && + now - e.at < FAILED_MAX_AGE_MS + ) { + out[k] = { index: e.index, retryCount: e.retryCount, at: e.at }; + } + } + return out; + } catch { + return {}; + } +} + +function writeFailedMap(map: Record): void { + try { + localStorage.setItem(FAILED_KEY, JSON.stringify(map)); + } catch { + // Private mode / quota — retry simply won't survive a refresh. + } +} + +export function getFailedEntry(convId: string): FailedEntry | null { + return readFailedMap()[convId] ?? null; +} + +export function setFailedEntry(convId: string, entry: FailedEntry): void { + const map = readFailedMap(); + map[convId] = entry; + writeFailedMap(map); +} + +export function clearFailedEntry(convId: string): void { + const map = readFailedMap(); + if (map[convId] !== undefined) { + delete map[convId]; + writeFailedMap(map); + } +} + +function modelKey(username: string): string { + return `ka_model:${username.trim().toLowerCase()}`; +} + +export function getSavedModel(username: string): string | null { + try { + return localStorage.getItem(modelKey(username)); + } catch { + return null; + } +} + +export function setSavedModel(username: string, id: string): void { + try { + localStorage.setItem(modelKey(username), id); + } catch { + // ignore — falls back to first model next boot + } +} + +/** Saved pick when still offered, otherwise the first available model. */ +export function pickModel(available: { id: string }[], saved: string | null): string { + if (saved && available.some((m) => m.id === saved)) return saved; + return available[0]?.id ?? ""; +} diff --git a/prompt_v2 b/prompt_v2 new file mode 100644 index 0000000..a66985a --- /dev/null +++ b/prompt_v2 @@ -0,0 +1,17 @@ +Il y as plusieurs bug d'UX bien chiant: +Quand un modèle as une erreur: Le modèle le dit, je refresh, le message d'errur à disparue -> Rendre ces message perisstant +Quand j'envoie un message dans une conversation , l'IA think: je vais sur une autre, sont overly aussi overide une autre conversation: bien segmenter sa +Le modèles choisis n'est pas sauvegarder, rend l'user choix perssistant + + +____ + +Pouvoir ajouter d'autre node ollama dans l'admin center. modale, composant, gestion de priorité avec des "poids" comme un firewall +Gestions des rate-limites pour recovery password... +admin center: gérer les url pages + +exemple: ouverture catégorie modèles: /admin/models +fait un timeout sur les requete envoyez au ollama , configurable dans l'admin center. +Faire un catalog des modèles ollama via internet /hugging face compatible ollama. +voir les détails des nodes ollama (charge cpu, mémoire, vram..., sur le temps (1h,6h,12h,24h,7w,14w,1m)) +leur composants,...