This commit is contained in:
k
2026-09-24 10:41:57 +02:00
parent 4005ce0631
commit 8e64c0c963
4 changed files with 187 additions and 17 deletions
+65 -17
View File
@@ -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<string | null>(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/<id> 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/<id>.
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}
+6
View File
@@ -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",
+99
View File
@@ -0,0 +1,99 @@
/**
* Browser-local UI persistence (no server round-trip, per browser profile).
*
* - Selected model per account (`ka_model:<username>`): 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<string, FailedEntry> {
try {
const raw = localStorage.getItem(FAILED_KEY);
if (!raw) return {};
const obj = JSON.parse(raw) as Record<string, unknown>;
const out: Record<string, FailedEntry> = {};
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<string, FailedEntry>): 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 ?? "";
}