修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可): - 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来 - 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知) 后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库) 载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」 前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级) + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表) + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result) + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑) 双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次 契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
180 lines
7.7 KiB
TypeScript
180 lines
7.7 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 { 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<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, 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 <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>
|
||
);
|
||
}
|