Update App.tsx
This commit is contained in:
+136
-40
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user