import { useEffect, useMemo, useState } from "react"; import { TopBar } from "./components/TopBar"; import { Sidebar, MobileNav } from "./components/Nav"; import { ModeRouter } from "./modes/ModeRouter"; import { AdminApp } from "./admin/AdminApp"; import { Login } from "./components/Login"; import { parseAdminRoute, readAdminEntry, subscribeAdminRoute } from "./admin/router"; import { connectWs, disconnectWs } from "./lib/ws"; import { useWsResync } from "./lib/useWsResync"; import { markSessionOpenedRead } from "./lib/sessionRead"; import { initTheme } from "./lib/theme"; import { fetchMessages, fetchPersonas, fetchSessions, getAuthToken, } from "./lib/api"; import type { SessionMeta } from "./lib/types"; import { useAppStore } from "./store/useAppStore"; import { personaMoodSeed } from "./store/personaSelectors"; export default function App(): React.ReactNode { // Apply the persisted (dark-first) theme and open the realtime channel only // when we hold a token — otherwise the gateway rejects the handshake with 401. // // 管理面板是**安全入口**(裁决 1.6 / 16.4.7,design/07 §0.2):入口前缀由后端 // 按 .env 的 ADMIN_PATH 注入到面板那一份 index.html // (`window.__CYRENE_ADMIN_ENTRY__`,见 internal/services/gateway/routes.go)。 // 用户端页面没有这个全局量,因此这里的 route.admin 恒为 false —— 用户端 // 既不含入口链接、也解析不出面板路由。面板模块切换走 history(不再读 hash, // hash 只留给渠道页的子页)。 const entry = useMemo(readAdminEntry, []); const [route, setRoute] = useState(() => parseAdminRoute(window.location.pathname, entry)); const [menuOpen, setMenuOpen] = useState(false); const setPersonas = useAppStore((s) => s.setPersonas); const seedMoods = useAppStore((s) => s.seedMoods); // WS 重连后重跑本页的引导读取(切片 P0 的顺带件):握手 history 回放只补**消息**, // 会话列表 / 角色目录 / 消息页这些初始 GET 在断线窗口里的变化没人补,页面会一直 // 停在旧态。这里只递增一个世代号,下面的加载副作用把它列进依赖即可(首次连接不触发, // 页面挂载时的初始加载已经做过一遍)。 const [resyncNonce, setResyncNonce] = useState(0); useWsResync(() => setResyncNonce((n) => n + 1)); useEffect(() => { initTheme(); if (getAuthToken()) connectWs(); const onRoute = () => setRoute(parseAdminRoute(window.location.pathname, entry)); const unsubscribe = subscribeAdminRoute(onRoute); return () => { disconnectWs(); unsubscribe(); }; }, [entry]); // Load the role catalog (design/22 §4.6). The rendered role is no longer one // global value: it is derived per active session from this catalog plus that // session's own `personaId` (see store/personaSelectors), so this fetch only has // to fill the catalog. It must not hinge on a single mount-time // request: a token may appear (login) after mount, or the fetch may transiently // fail — in which case the avatar/name would stay on the neutral placeholder. We // re-run when the token appears, and retry on failure. The catalog is persisted // by useAppStore, so a refresh renders the right role before the fetch returns. // // The catalog also carries each role's *role-level* mood (design/01 §2: emotion // follows the role), which seeds the mood bucket of every role it names — that is // what stops the 心情 badge from flashing empty after a refresh. Seeding merges // (see `seedMoods`): a role the backend reports no mood for is skipped, so a WS // `mood` frame that landed first is never cleared. const token = getAuthToken(); useEffect(() => { if (!token) return; let cancelled = false; let retry: number | null = null; const load = () => { if (cancelled) return; fetchPersonas() .then((list) => { if (cancelled) return; setPersonas(list); seedMoods(personaMoodSeed(list)); }) .catch(() => { // Transient failure — retry rather than leaving the placeholder forever. if (!cancelled) retry = window.setTimeout(load, 3000); }); }; load(); return () => { cancelled = true; if (retry !== null) window.clearTimeout(retry); }; }, [token, setPersonas, seedMoods, resyncNonce]); const sessions = useAppStore((s) => s.sessions); const activeSessionId = useAppStore((s) => s.activeSessionId); const setSessions = useAppStore((s) => s.setSessions); const setActiveSession = useAppStore((s) => s.setActiveSession); const setMessages = useAppStore((s) => s.setMessages); // Load the session list once we hold a token, then focus a session: a persisted // (still-present) session id, else the main session (design/16 §4.1). The main // session has `type === "main"`; fall back to the first entry, then clear. useEffect(() => { if (!token) return; let cancelled = false; fetchSessions() .then((list) => { if (cancelled) return; const byId = Object.fromEntries( list.map((s) => [s.id, s] as const) ) as Record; setSessions(list); const main = list.find((s) => s.type === "main") ?? list[0]; const next = activeSessionId && byId[activeSessionId] ? activeSessionId : main?.id; setActiveSession(next ?? ""); }) .catch(() => { // Backend not wired yet — leave the shell in its untouched state. }); return () => { cancelled = true; }; }, [token, setSessions, setActiveSession, resyncNonce]); // Whenever the focused session changes (switch, new, or first load) and it is // a session we know about, pull its initial page of history into the panel, then // advance that session's read cursor (协同 B3/D4) — *after* the page lands, so the // cursor never passes messages the user was never shown. The effect hinges on // *whether* the session is known rather than on the list's identity: clearing the // unread badge rewrites `sessions`, and keying on it would re-fetch history (and // so double every session switch) for a metadata-only change. const activeSessionKnown = Boolean(sessions[activeSessionId]); useEffect(() => { if (!activeSessionId || !activeSessionKnown) return; let cancelled = false; fetchMessages(activeSessionId, undefined, 50) .then((list) => { if (cancelled) return; setMessages(list); markSessionOpenedRead(activeSessionId); }) .catch(() => { if (!cancelled) setMessages([]); }); return () => { cancelled = true; }; }, [activeSessionId, activeSessionKnown, setMessages, resyncNonce]); // The M9 modal UI and the admin subsystem (design/10) are two separate shells. // 面板由安全入口路径进入(见上方 route 注释),每个模块是前缀下的一段路径。 if (route.admin) { return ; } // No credential: never render the chat shell (it would silently show an // "未登录" shell with no way in). Show the user-side login card instead; its // onSuccess reloads the page so connectWs + session/persona effects re-run // with the fresh token. Admin shell keeps its own LoginGate. if (!getAuthToken()) { return window.location.reload()} />; } return (
setMenuOpen(true)} />
setMenuOpen(false)} />
); }