Files
Cyrene_v2/frontend/web/src/App.tsx
T
AskaEth 84c124f5a9
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 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 成功再打活跃 ⇒ 全部打红
2026-10-05 16:02:36 +08:00

180 lines
7.7 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 { 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>
);
}