返回 JoyAI-Echo
App.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 import { useTranslation } from "react-i18next";
3 import { Clapperboard, PanelLeftOpen } from "lucide-react";
4 import { DeleteConfirm } from "@/components/DeleteConfirm";
5 import { LongVideoGenPage } from "@/components/longvideo/LongVideoGenPage";
6 import type { WorkflowMode } from "@/components/director/WorkflowSelector";
7 import { Sidebar } from "@/components/Sidebar";
8 import { Button } from "@/components/ui/button";
9 import { Sheet, SheetContent } from "@/components/ui/sheet";
10 import { preloadMarkdownText } from "@/components/MarkdownText";
11 import { useSessions } from "@/hooks/useSessions";
12 import { cn } from "@/lib/utils";
13 import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
14 import { deriveTitle } from "@/lib/format";
15 import { NanobotClient } from "@/lib/nanobot-client";
16 import { webuiSessionKey, webuiChatIdFromKey } from "@/lib/session-key";
17 import { ClientProvider, useClient } from "@/providers/ClientProvider";
18 import type { ChatSummary } from "@/lib/types";
19
20 type BootState =
21 | { status: "loading" }
22 | { status: "error"; message: string }
23 | {
24 status: "ready";
25 client: NanobotClient;
26 token: string;
27 modelName: string | null;
28 webuiUserId: string | null;
29 };
30
31 const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
32 const SIDEBAR_WIDTH = 279;
33 const AUTH_REFRESH_SKEW_S = 30;
34 const AUTH_REFRESH_RETRY_MS = 10_000;
35
36 function readSidebarOpen(): boolean {
37 if (typeof window === "undefined") return true;
38 try {
39 const raw = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
40 if (raw === null) return true;
41 return raw === "1";
42 } catch {
43 return true;
44 }
45 }
46
47 export default function App() {
48 const { t } = useTranslation();
49 const [state, setState] = useState<BootState>({ status: "loading" });
50 const [bootAttempt] = useState(0);
51
52 useEffect(() => {
53 let cancelled = false;
54 let refreshTimer: ReturnType<typeof setTimeout> | null = null;
55 let clientInstance: NanobotClient | null = null;
56
57 const clearRefreshTimer = () => {
58 if (refreshTimer !== null) {
59 clearTimeout(refreshTimer);
60 refreshTimer = null;
61 }
62 };
63
64 const scheduleRefresh = (client: NanobotClient, expiresIn: number) => {
65 clearRefreshTimer();
66 const delayMs = Math.max(
67 5_000,
68 (expiresIn - AUTH_REFRESH_SKEW_S) * 1_000,
69 );
70 refreshTimer = setTimeout(() => {
71 void refreshAuth(client);
72 }, delayMs);
73 };
74
75 const refreshAuth = async (
76 client: NanobotClient,
77 ): Promise<string | null> => {
78 try {
79 const refreshed = await fetchBootstrap();
80 const url = deriveWsUrl(refreshed.ws_path, refreshed.token);
81 client.updateUrl(url);
82 if (!cancelled) {
83 setState((current) =>
84 current.status === "ready" && current.client === client
85 ? {
86 ...current,
87 token: refreshed.token,
88 modelName: refreshed.model_name ?? current.modelName,
89 webuiUserId: refreshed.user_id ?? current.webuiUserId,
90 }
91 : current,
92 );
93 scheduleRefresh(client, refreshed.expires_in);
94 }
95 return url;
96 } catch (e) {
97 if (!cancelled) {
98 clearRefreshTimer();
99 refreshTimer = setTimeout(() => {
100 void refreshAuth(client);
101 }, AUTH_REFRESH_RETRY_MS);
102 }
103 return null;
104 }
105 };
106
107 (async () => {
108 try {
109 const boot = await fetchBootstrap();
110 if (cancelled) return;
111 const url = deriveWsUrl(boot.ws_path, boot.token);
112 let client: NanobotClient;
113 client = new NanobotClient({
114 url,
115 onReauth: async () => {
116 return refreshAuth(client);
117 },
118 });
119 clientInstance = client;
120 client.connect();
121 setState({
122 status: "ready",
123 client,
124 token: boot.token,
125 modelName: boot.model_name ?? null,
126 webuiUserId: boot.user_id ?? null,
127 });
128 scheduleRefresh(client, boot.expires_in);
129 } catch (e) {
130 if (cancelled) return;
131 setState({ status: "error", message: (e as Error).message });
132 }
133 })();
134 return () => {
135 cancelled = true;
136 clearRefreshTimer();
137 clientInstance?.close();
138 };
139 }, [bootAttempt]);
140
141 useEffect(() => {
142 const warm = () => preloadMarkdownText();
143 const win = globalThis as typeof globalThis & {
144 requestIdleCallback?: (
145 callback: IdleRequestCallback,
146 options?: IdleRequestOptions,
147 ) => number;
148 cancelIdleCallback?: (handle: number) => void;
149 };
150 if (typeof win.requestIdleCallback === "function") {
151 const id = win.requestIdleCallback(warm, { timeout: 1500 });
152 return () => win.cancelIdleCallback?.(id);
153 }
154 const id = globalThis.setTimeout(warm, 250);
155 return () => globalThis.clearTimeout(id);
156 }, []);
157
158 if (state.status === "loading") {
159 return (
160 <div className="flex h-full w-full items-center justify-center">
161 <div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
162 <Clapperboard
163 className="h-10 w-10 animate-pulse text-foreground/60"
164 aria-hidden
165 />
166 <div className="flex items-center gap-2 text-sm text-muted-foreground">
167 <span className="relative flex h-2 w-2">
168 <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
169 <span className="relative inline-flex h-2 w-2 rounded-full bg-foreground/60" />
170 </span>
171 {t("app.loading.connecting")}
172 </div>
173 </div>
174 </div>
175 );
176 }
177 if (state.status === "error") {
178 return (
179 <div className="flex h-full w-full items-center justify-center px-4 text-center">
180 <div className="flex max-w-md flex-col items-center gap-3">
181 <Clapperboard
182 className="h-10 w-10 text-foreground/40"
183 aria-hidden
184 />
185 <p className="text-lg font-semibold">{t("app.error.title")}</p>
186 <p className="text-sm text-muted-foreground">{state.message}</p>
187 <p className="text-xs text-muted-foreground">
188 {t("app.error.gatewayHint")}
189 </p>
190 </div>
191 </div>
192 );
193 }
194
195 return (
196 <ClientProvider
197 client={state.client}
198 token={state.token}
199 modelName={state.modelName}
200 webuiUserId={state.webuiUserId}
201 >
202 <Shell />
203 </ClientProvider>
204 );
205 }
206
207 function Shell() {
208 const { t, i18n } = useTranslation();
209 const { webuiUserId } = useClient();
210 const { sessions, loading, refresh, createChat, deleteChat } = useSessions();
211 const [activeKey, setActiveKey] = useState<string | null>(null);
212 const [desktopSidebarOpen, setDesktopSidebarOpen] =
213 useState<boolean>(readSidebarOpen);
214 const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
215 const [pendingDelete, setPendingDelete] = useState<{
216 key: string;
217 label: string;
218 } | null>(null);
219 const lastSessionsLen = useRef(0);
220 const skipAutoSelectRef = useRef(false);
221 const pendingNewChatRef = useRef(false);
222 const creatingChatRef = useRef(false);
223 const [workflowMode, setWorkflowMode] = useState<
224 "unselected" | WorkflowMode
225 >("unselected");
226
227 useEffect(() => {
228 if (!activeKey) {
229 if (!pendingNewChatRef.current) setWorkflowMode("unselected");
230 return;
231 }
232 pendingNewChatRef.current = false;
233 const session = sessions.find((item) => item.key === activeKey);
234 setWorkflowMode(session?.autoGenerate ? "quick" : "director");
235 }, [activeKey, sessions]);
236 useEffect(() => {
237 try {
238 window.localStorage.setItem(
239 SIDEBAR_STORAGE_KEY,
240 desktopSidebarOpen ? "1" : "0",
241 );
242 } catch {
243 // ignore storage errors (private mode, etc.)
244 }
245 }, [desktopSidebarOpen]);
246
247 useEffect(() => {
248 if (activeKey) return;
249 if (skipAutoSelectRef.current) {
250 skipAutoSelectRef.current = false;
251 lastSessionsLen.current = sessions.length;
252 return;
253 }
254 if (sessions.length > 0 && lastSessionsLen.current === 0) {
255 setActiveKey(sessions[0].key);
256 }
257 lastSessionsLen.current = sessions.length;
258 }, [sessions, activeKey]);
259
260 useEffect(() => {
261 if (!activeKey || !webuiUserId) return;
262 const chatId = webuiChatIdFromKey(activeKey);
263 const canonical = webuiSessionKey(webuiUserId, chatId);
264 if (canonical === activeKey) return;
265 if (sessions.some((s) => s.key === canonical)) {
266 setActiveKey(canonical);
267 }
268 }, [activeKey, sessions, webuiUserId]);
269
270 const activeSession = useMemo<ChatSummary | null>(() => {
271 if (!activeKey) return null;
272 return sessions.find((s) => s.key === activeKey) ?? null;
273 }, [sessions, activeKey]);
274
275 const closeDesktopSidebar = useCallback(() => {
276 setDesktopSidebarOpen(false);
277 }, []);
278
279 const openDesktopSidebar = useCallback(() => {
280 setDesktopSidebarOpen(true);
281 }, []);
282
283 const closeMobileSidebar = useCallback(() => {
284 setMobileSidebarOpen(false);
285 }, []);
286
287 const toggleSidebar = useCallback(() => {
288 const isDesktop =
289 typeof window !== "undefined" &&
290 window.matchMedia("(min-width: 1024px)").matches;
291 if (isDesktop) {
292 setDesktopSidebarOpen((v) => !v);
293 } else {
294 setMobileSidebarOpen((v) => !v);
295 }
296 }, []);
297
298 const onNewChat = useCallback(async () => {
299 try {
300 const chatId = await createChat({
301 autoGenerate: workflowMode === "quick",
302 });
303 pendingNewChatRef.current = false;
304 setActiveKey(webuiSessionKey(webuiUserId, chatId));
305 setMobileSidebarOpen(false);
306 return chatId;
307 } catch (e) {
308 console.error("Failed to create chat", e);
309 return null;
310 }
311 }, [createChat, webuiUserId, workflowMode]);
312
313 const onSidebarNewChat = useCallback(() => {
314 pendingNewChatRef.current = true;
315 skipAutoSelectRef.current = true;
316 setActiveKey(null);
317 setWorkflowMode("unselected");
318 setMobileSidebarOpen(false);
319 }, []);
320
321 const onWorkflowSelect = useCallback(
322 (mode: WorkflowMode) => {
323 if (activeKey || creatingChatRef.current) return;
324 creatingChatRef.current = true;
325 pendingNewChatRef.current = true;
326 setWorkflowMode(mode);
327 void createChat({ autoGenerate: mode === "quick" })
328 .then((chatId) => {
329 setActiveKey(webuiSessionKey(webuiUserId, chatId));
330 })
331 .catch((error) => {
332 console.error("Failed to create chat", error);
333 setWorkflowMode("unselected");
334 })
335 .finally(() => {
336 pendingNewChatRef.current = false;
337 creatingChatRef.current = false;
338 });
339 },
340 [activeKey, createChat, webuiUserId],
341 );
342
343 const onSelectChat = useCallback((key: string) => {
344 pendingNewChatRef.current = false;
345 setActiveKey(key);
346 setMobileSidebarOpen(false);
347 }, []);
348
349 const onConfirmDelete = useCallback(async () => {
350 if (!pendingDelete) return;
351 const key = pendingDelete.key;
352 const deletingActive = activeKey === key;
353 const currentIndex = sessions.findIndex((s) => s.key === key);
354 const fallbackKey = deletingActive
355 ? (sessions[currentIndex + 1]?.key ??
356 sessions[currentIndex - 1]?.key ??
357 null)
358 : activeKey;
359 setPendingDelete(null);
360 if (deletingActive) setActiveKey(fallbackKey);
361 if (pendingNewChatRef.current) {
362 pendingNewChatRef.current = false;
363 }
364 try {
365 await deleteChat(key);
366 } catch (e) {
367 if (deletingActive) setActiveKey(key);
368 console.error("Failed to delete session", e);
369 }
370 }, [pendingDelete, deleteChat, activeKey, sessions]);
371
372 const headerTitle = activeSession
373 ? deriveTitle(
374 activeSession.preview,
375 t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }),
376 )
377 : t("app.brand");
378
379 useEffect(() => {
380 document.title = activeSession
381 ? t("app.documentTitle.chat", { title: headerTitle })
382 : t("app.documentTitle.base");
383 }, [activeSession, headerTitle, i18n.resolvedLanguage, t]);
384
385 const sidebarProps = {
386 sessions,
387 activeKey,
388 loading,
389 onNewChat: () => {
390 void onSidebarNewChat();
391 },
392 onSelect: onSelectChat,
393 onRequestDelete: (key: string, label: string) =>
394 setPendingDelete({ key, label }),
395 };
396
397 return (
398 <div className="relative flex h-full w-full overflow-hidden">
399 {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
400 <aside
401 className={cn(
402 "relative z-20 hidden shrink-0 overflow-hidden lg:block",
403 "transition-[width] duration-300 ease-out",
404 )}
405 style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
406 >
407 <div
408 className={cn(
409 "absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right",
410 "transition-transform duration-300 ease-out",
411 desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
412 )}
413 >
414 <Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
415 </div>
416 </aside>
417
418 {/* 桌面端侧栏收起后,由布局层提供展开入口(不依赖各业务页 Header) */}
419 {!desktopSidebarOpen ? (
420 <div className="absolute left-0 top-0 z-30 hidden px-3 py-2 lg:block">
421 <Button
422 variant="ghost"
423 size="icon"
424 aria-label={t("thread.header.toggleSidebar")}
425 onClick={openDesktopSidebar}
426 className="h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground"
427 >
428 <PanelLeftOpen className="h-3.5 w-3.5" />
429 </Button>
430 </div>
431 ) : null}
432
433 <Sheet
434 open={mobileSidebarOpen}
435 onOpenChange={(open) => setMobileSidebarOpen(open)}
436 >
437 <SheetContent
438 side="left"
439 showCloseButton={false}
440 className="w-[279px] p-0 sm:max-w-[279px] lg:hidden"
441 >
442 <Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
443 </SheetContent>
444 </Sheet>
445
446 <main className="flex h-full min-w-0 flex-1 flex-col">
447 <LongVideoGenPage
448 mode={workflowMode}
449 onModeChange={onWorkflowSelect}
450 session={activeSession}
451 title={headerTitle}
452 onToggleSidebar={toggleSidebar}
453 onGoHome={() => setActiveKey(null)}
454 onNewChat={onNewChat}
455 hideSidebarToggleOnDesktop
456 onReplyEnd={() => {
457 // Preview is written after the turn saves; brief delay matches manual refresh timing.
458 window.setTimeout(() => {
459 void refresh();
460 }, 1500);
461 }}
462 />
463 </main>
464
465 <DeleteConfirm
466 open={!!pendingDelete}
467 title={pendingDelete?.label ?? ""}
468 onCancel={() => setPendingDelete(null)}
469 onConfirm={onConfirmDelete}
470 />
471 </div>
472 );
473 }
474
474 lines Plain Text