- 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」
173 lines
7.1 KiB
TypeScript
173 lines
7.1 KiB
TypeScript
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>
|
||
);
|
||
}
|