Files
Cyrene_v2/frontend/web/src/App.tsx
T
AskaEth a398dc9b92
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,gateway,web): 路径式安全入口 ADMIN_PATH + 数据目录 platform 哨兵(裁决 1.6/16.4.7/12.4.2 + 1.5/12.4.3 核对)
- 1.6 + 16.4.7:安全入口=路径式 `/<ADMIN_PATH>`(env `ADMIN_PATH`,默认 `admin`;`xxx/admin` 亦可,
  非法值回退 `/admin`)。命中前缀 → 送注入了 `<base href="/">` + `window.__CYRENE_ADMIN_ENTRY__` 的面板入口页
  (深链直达);**形如面板入口但前缀不符 → 404**(不回落用户端聊天壳);`/api/*`、`/ws`、webhook 先放行
  ⇒ **API 不藏**。前端路由改**路径式**(`router.ts` 只读注入,无硬编兜底;移除 hash 入口)
- 1.5 / 12.4.3 核对:暴露面默认 `0.0.0.0` 与裁决一致,无需改代码(仅补注释)
- 12.4.2:`DATA_DIR` 支持 `platform` 哨兵(大小写不敏感)→ 平台标准目录;默认 `./data` 与显式空值行为不变
  (显式空值保持原兜底,避免正在运行实例突然换库)
- 复核:build/vet 绿 + config/gateway 包测试绿 + linux/darwin 交叉编译绿;新增 7 条用例,
  并用真实构建产物(`webui.Assets()`)跑通「浏览器直开 /admin 能启动面板」(`<base>` 必须在资源引用之前)
- 变异 2 条(关掉错前缀 404 / 前端默认前缀兜底)已还原
- ⚠️ 运行时序:前端已生效而后端 hatch 未生效期间**面板打不开** ⇒ 需紧接一次「build 到位 + restart」
2026-10-04 14:00:25 +08:00

173 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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);
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]);
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<string, SessionMeta>;
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]);
// 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]);
// The M9 modal UI and the admin subsystem (design/10) are two separate shells.
// 面板由安全入口路径进入(见上方 route 注释),每个模块是前缀下的一段路径。
if (route.admin) {
return <AdminApp section={route.section} />;
}
// 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 <Login onSuccess={() => window.location.reload()} />;
}
return (
<div className="flex h-full flex-col">
<TopBar onMenu={() => setMenuOpen(true)} />
<div className="flex min-h-0 flex-1">
<Sidebar />
<main className="relative min-h-0 flex-1 overflow-y-auto">
<ModeRouter />
</main>
</div>
<MobileNav open={menuOpen} onClose={() => setMenuOpen(false)} />
</div>
);
}