Update App.tsx

This commit is contained in:
k
2026-09-21 19:25:51 +02:00
parent 5903b77d95
commit 62370e61bf
+136 -40
View File
@@ -1,13 +1,25 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api, clearToken, getToken } from "./lib/api.ts"; import { api, clearToken, getToken } from "./lib/api.ts";
import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts"; import { featureEnabled, THINKING_SYSTEM_PROMPT } from "./lib/features.ts";
import type { Attachment, ChatMessage, Conversation, DriverModel, FilePreview, User } from "./lib/types.ts"; import type {
Attachment,
ChatMessage,
Conversation,
DriverModel,
FilePreview,
User,
} from "./lib/types.ts";
import { navigate, normalizePath, useRoute } from "./lib/route.ts"; import { navigate, normalizePath, useRoute } from "./lib/route.ts";
import { Login } from "./components/Login.tsx"; import { Login } from "./components/Login.tsx";
import { RegisterPage } from "./components/RegisterPage.tsx"; import { RegisterPage } from "./components/RegisterPage.tsx";
import { Sidebar } from "./components/Sidebar.tsx"; import { Sidebar } from "./components/Sidebar.tsx";
import { Chat } from "./components/Chat.tsx"; import { Chat } from "./components/Chat.tsx";
import { ConvEditDialog, FilePreviewModal, SettingsModal, ShareModal } from "./components/dialogs.tsx"; import {
ConvEditDialog,
FilePreviewModal,
SettingsModal,
ShareModal,
} from "./components/dialogs.tsx";
import { AdminCenter } from "./components/AdminCenter.tsx"; import { AdminCenter } from "./components/AdminCenter.tsx";
import { SharePage } from "./components/SharePage.tsx"; import { SharePage } from "./components/SharePage.tsx";
import { ResetPage } from "./components/ResetPage.tsx"; import { ResetPage } from "./components/ResetPage.tsx";
@@ -16,7 +28,9 @@ import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx";
import { useT } from "./lib/i18n.ts"; import { useT } from "./lib/i18n.ts";
function shareIdFromPath(): string | null { function shareIdFromPath(): string | null {
const m = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/); const m = window.location.pathname.match(
/^\/share\/([A-Za-z0-9_-]{6,64})\/?$/,
);
return m ? m[1]! : null; return m ? m[1]! : null;
} }
@@ -24,10 +38,14 @@ function shareIdFromPath(): string | null {
const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null; const SHARE_ID = typeof window !== "undefined" ? shareIdFromPath() : null;
const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null; const RESET_TOKEN = typeof window !== "undefined" ? resetTokenFromPath() : null;
const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null; const REVIEW_ID = typeof window !== "undefined" ? reviewIdFromPath() : null;
const IS_ADMIN_PAGE = typeof window !== "undefined" && /^\/admin\/?$/.test(window.location.pathname); const IS_ADMIN_PAGE =
typeof window !== "undefined" &&
/^\/admin\/?$/.test(window.location.pathname);
function resetTokenFromPath(): string | null { function resetTokenFromPath(): string | null {
const m = window.location.pathname.match(/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/); const m = window.location.pathname.match(
/^\/reset\/([A-Za-z0-9_-]{6,80})\/?$/,
);
return m ? m[1]! : null; return m ? m[1]! : null;
} }
@@ -38,12 +56,14 @@ function reviewIdFromPath(): string | null {
/** Minimum time the boot splash stays visible, so the bloom animation /** Minimum time the boot splash stays visible, so the bloom animation
* actually plays even when the session restores instantly. */ * actually plays even when the session restores instantly. */
const MIN_SPLASH_MS = 1500; const MIN_SPLASH_MS = 1100;
function applyTheme(theme: string) { function applyTheme(theme: string) {
const root = document.documentElement; const root = document.documentElement;
const dark = const dark =
theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); theme === "dark" ||
(theme !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
root.classList.toggle("dark", dark); root.classList.toggle("dark", dark);
root.style.colorScheme = dark ? "dark" : "light"; root.style.colorScheme = dark ? "dark" : "light";
} }
@@ -104,7 +124,11 @@ export function App() {
setConvs(res.conversations); setConvs(res.conversations);
if (select) setActiveId(select); if (select) setActiveId(select);
else if (res.conversations.length > 0) { else if (res.conversations.length > 0) {
setActiveId((cur) => (cur && res.conversations.some((c) => c.id === cur) ? cur : res.conversations[0]!.id)); setActiveId((cur) =>
cur && res.conversations.some((c) => c.id === cur)
? cur
: res.conversations[0]!.id,
);
} }
} catch { } catch {
// ignore (session errors reload the page via api layer) // ignore (session errors reload the page via api layer)
@@ -123,14 +147,19 @@ export function App() {
setChecking(false); setChecking(false);
return; return;
} }
api.verify() api
.verify()
.then(async (res) => { .then(async (res) => {
setUser(res.user); setUser(res.user);
applyTheme(res.user.theme); applyTheme(res.user.theme);
const [m, c, t] = await Promise.all([ const [m, c, t] = await Promise.all([
api.models().catch(() => ({ models: [] })), api.models().catch(() => ({ models: [] })),
api.convs().catch(() => ({ conversations: [] })), api.convs().catch(() => ({ conversations: [] })),
api.tools().catch(() => ({ tools: [] as { name: string; available: boolean }[] })), api
.tools()
.catch(() => ({
tools: [] as { name: string; available: boolean }[],
})),
]); ]);
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)); setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
setModels(m.models); setModels(m.models);
@@ -174,7 +203,10 @@ export function App() {
setMessages([]); setMessages([]);
return; return;
} }
api.getConv(activeId).then((res) => setMessages(res.messages)).catch(() => setMessages([])); api
.getConv(activeId)
.then((res) => setMessages(res.messages))
.catch(() => setMessages([]));
}, [activeId]); }, [activeId]);
// Follow OS theme when account theme is auto // Follow OS theme when account theme is auto
@@ -192,11 +224,19 @@ export function App() {
setChecking(false); setChecking(false);
applyTheme(u.theme); applyTheme(u.theme);
navigate("/chat", true); navigate("/chat", true);
api.models().then((m) => { api
setModels(m.models); .models()
if (m.models.length > 0) setModel(m.models[0]!.id); .then((m) => {
}).catch(() => {}); setModels(m.models);
api.tools().then((t) => setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available))).catch(() => {}); if (m.models.length > 0) setModel(m.models[0]!.id);
})
.catch(() => {});
api
.tools()
.then((t) =>
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available)),
)
.catch(() => {});
refreshConvs(); refreshConvs();
} }
@@ -239,11 +279,15 @@ export function App() {
} }
const userMsg: ChatMessage = { role: "user", content: text }; const userMsg: ChatMessage = { role: "user", content: text };
// Attachments travel as reviewed text blocks appended to the message. // Attachments travel as reviewed text blocks appended to the message.
const blocks = attachments.map((a) => const blocks = attachments.map(
t("attach.tpl", { kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"), name: a.name }) + (a) =>
`\n\`\`\`text\n${a.text}\n\`\`\``, t("attach.tpl", {
kind: t(a.kind === "ocr" ? "attach.kindOcr" : "attach.kindText"),
name: a.name,
}) + `\n\`\`\`text\n${a.text}\n\`\`\``,
); );
if (blocks.length > 0) userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n"); if (blocks.length > 0)
userMsg.content = [text, ...blocks].filter(Boolean).join("\n\n");
const history = [...messages, userMsg]; const history = [...messages, userMsg];
setMessages(history); setMessages(history);
setAttachments([]); setAttachments([]);
@@ -254,17 +298,26 @@ export function App() {
// "Thinking" feature flag: decorate the outgoing payload only — the // "Thinking" feature flag: decorate the outgoing payload only — the
// system message is never shown nor persisted client-side. // system message is never shown nor persisted client-side.
const outMessages = featureEnabled("thinking") const outMessages = featureEnabled("thinking")
? [{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage, ...history] ? [
{ role: "system", content: THINKING_SYSTEM_PROMPT } as ChatMessage,
...history,
]
: history; : history;
await api.chatStream({ model, messages: outMessages, conversationId: convId! }, (t) => { await api.chatStream(
full += t; { model, messages: outMessages, conversationId: convId! },
setStreaming(full); (t) => {
}); full += t;
setStreaming(full);
},
);
setMessages([...history, { role: "assistant", content: full }]); setMessages([...history, { role: "assistant", content: full }]);
setStreaming(""); setStreaming("");
await refreshConvs(); await refreshConvs();
} catch { } catch {
setMessages([...history, { role: "assistant", content: t("attach.chatError") }]); setMessages([
...history,
{ role: "assistant", content: t("attach.chatError") },
]);
setStreaming(""); setStreaming("");
} finally { } finally {
setSending(false); setSending(false);
@@ -288,16 +341,28 @@ export function App() {
try { try {
if (kind === "ocr") { if (kind === "ocr") {
const r = await api.ocrImage(file); const r = await api.ocrImage(file);
setFilePreview({ name: file.name, kind, text: r.text, truncated: r.truncated }); setFilePreview({
name: file.name,
kind,
text: r.text,
truncated: r.truncated,
});
} else { } else {
const key = `${user.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`; const key = `${user.id}-${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
const put = await fetch(`/cdn/text/${key}`, { const put = await fetch(`/cdn/text/${key}`, {
method: "PUT", method: "PUT",
headers: { Authorization: `Bearer ${getToken()}`, "Content-Type": "text/plain" }, headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "text/plain",
},
body: file, body: file,
}); });
const pdata = (await put.json().catch(() => ({}))) as { error?: string; url?: string }; const pdata = (await put.json().catch(() => ({}))) as {
if (!put.ok || !pdata.url) throw new Error(pdata.error || `Upload failed (${put.status})`); error?: string;
url?: string;
};
if (!put.ok || !pdata.url)
throw new Error(pdata.error || `Upload failed (${put.status})`);
const text = await (await fetch(pdata.url)).text(); const text = await (await fetch(pdata.url)).text();
setFilePreview({ name: file.name, kind, text, truncated: false }); setFilePreview({ name: file.name, kind, text, truncated: false });
} }
@@ -311,11 +376,20 @@ export function App() {
function attachPreviewText(text: string) { function attachPreviewText(text: string) {
if (!filePreview || !text.trim()) return; if (!filePreview || !text.trim()) return;
setAttachments((prev) => [...prev, { id: crypto.randomUUID(), name: filePreview.name, kind: filePreview.kind, text }]); setAttachments((prev) => [
...prev,
{
id: crypto.randomUUID(),
name: filePreview.name,
kind: filePreview.kind,
text,
},
]);
setFilePreview(null); setFilePreview(null);
} }
async function removeConv(c: Conversation) { try { async function removeConv(c: Conversation) {
try {
await api.deleteConv(c.id); await api.deleteConv(c.id);
if (activeId === c.id) { if (activeId === c.id) {
setActiveId(null); setActiveId(null);
@@ -352,7 +426,10 @@ export function App() {
setArchivedConv(c); setArchivedConv(c);
setActiveId(null); setActiveId(null);
setMessages([]); setMessages([]);
api.getConv(c.id).then((res) => setMessages(res.messages)).catch(() => setMessages([])); api
.getConv(c.id)
.then((res) => setMessages(res.messages))
.catch(() => setMessages([]));
} }
/** Unarchive the currently viewed archived chat and select it. */ /** Unarchive the currently viewed archived chat and select it. */
@@ -414,15 +491,27 @@ export function App() {
activeId={activeId} activeId={activeId}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(true)} onToggle={() => setSidebarCollapsed(true)}
onNew={() => { newChat(); setMobileNav(false); }} onNew={() => {
onSelect={(id) => { setArchivedConv(null); setActiveId(id); setMobileNav(false); }} newChat();
setMobileNav(false);
}}
onSelect={(id) => {
setArchivedConv(null);
setActiveId(id);
setMobileNav(false);
}}
onRename={setEditConv} onRename={setEditConv}
onTopic={setEditConv} onTopic={setEditConv}
onShare={setShareConv} onShare={setShareConv}
onArchive={archiveConv} onArchive={archiveConv}
onDelete={setDeleteConv} onDelete={setDeleteConv}
onOpenSettings={() => { setSettingsOpen(true); setMobileNav(false); }} onOpenSettings={() => {
onOpenAdmin={() => { window.location.href = "/admin"; }} setSettingsOpen(true);
setMobileNav(false);
}}
onOpenAdmin={() => {
window.location.href = "/admin";
}}
onLogout={logout} onLogout={logout}
mobileOpen={mobileNav} mobileOpen={mobileNav}
onCloseMobile={() => setMobileNav(false)} onCloseMobile={() => setMobileNav(false)}
@@ -446,13 +535,17 @@ export function App() {
attachments={attachments} attachments={attachments}
attachError={attachError} attachError={attachError}
ocrAvailable={ocrAvailable} ocrAvailable={ocrAvailable}
onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((a) => a.id !== id))} onRemoveAttachment={(id) =>
setAttachments((prev) => prev.filter((a) => a.id !== id))
}
onPickFile={handlePickFile} onPickFile={handlePickFile}
/> />
<FilePreviewModal <FilePreviewModal
preview={filePreview} preview={filePreview}
busy={previewBusy} busy={previewBusy}
onClose={() => { if (!previewBusy) setFilePreview(null); }} onClose={() => {
if (!previewBusy) setFilePreview(null);
}}
onAttach={attachPreviewText} onAttach={attachPreviewText}
/> />
@@ -479,7 +572,10 @@ export function App() {
applyTheme(u.theme); applyTheme(u.theme);
}} }}
// Key rotated or account deleted: sessions are dead server-side. // Key rotated or account deleted: sessions are dead server-side.
onKeyRotated={() => { setSettingsOpen(false); logout(); }} onKeyRotated={() => {
setSettingsOpen(false);
logout();
}}
onViewArchived={openArchived} onViewArchived={openArchived}
onArchivedChanged={handleArchivedChanged} onArchivedChanged={handleArchivedChanged}
/> />