diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json
index b6851bd..cf550ca 100644
--- a/docs/governance/architecture-inventory.json
+++ b/docs/governance/architecture-inventory.json
@@ -254,8 +254,8 @@
"code_hotspots": [
{
"path": "static/app.js",
- "bytes": 449457,
- "lines": 9447
+ "bytes": 447568,
+ "lines": 9403
},
{
"path": "static/styles.css",
@@ -274,8 +274,8 @@
},
{
"path": "static/index.html",
- "bytes": 133569,
- "lines": 1871
+ "bytes": 133691,
+ "lines": 1873
},
{
"path": "database.py",
diff --git a/docs/governance/stage-14-frontend-boundaries.md b/docs/governance/stage-14-frontend-boundaries.md
new file mode 100644
index 0000000..f584a25
--- /dev/null
+++ b/docs/governance/stage-14-frontend-boundaries.md
@@ -0,0 +1,38 @@
+# Stage 14: Frontend Request and State Boundaries
+
+## Request Boundary
+
+`static/shared/api.js` is now the only application file allowed to call `fetch`. It owns:
+
+- JSON serialization and response parsing;
+- CSRF attachment for mutating requests;
+- expired-session notification;
+- abort signals;
+- NDJSON stream decoding and normalized stream errors.
+
+The mentor and review-assistant streams use the same client as ordinary API requests. Existing
+function signatures and page interactions remain unchanged.
+
+## State Boundary
+
+`static/shared/state.js` stores mutable state in explicit domains: session, market, entity
+details, review, screener, mentor, and heaven. A compatibility proxy retains the existing flat
+access syntax while rejecting unregistered fields. New page modules can request their owned
+domain without depending on another page's data.
+
+This stage establishes a migration boundary rather than splitting the build-free monolith in a
+single high-risk edit. Page extraction can now proceed domain by domain with no contract change.
+
+## Enforcement
+
+Automated checks require that:
+
+- only `shared/api.js` contains browser `fetch` calls;
+- shared state and API scripts load before `app.js`;
+- application state is created through the shared state boundary.
+
+## Residual Risk
+
+`static/app.js` still contains page renderers and event handlers in one file. The state domains
+make ownership explicit, but those functions should move into page modules only in later,
+independently verified stages.
diff --git a/static/app.js b/static/app.js
index b81e1d9..1629ef4 100644
--- a/static/app.js
+++ b/static/app.js
@@ -21,166 +21,185 @@ const THEME_STORAGE_KEY = "xiaobaiTheme";
let activeThemeTransition = null;
let themeSwitchSequence = 0;
-const state = {
- user: null,
- csrfToken: "",
- authMode: "login",
- started: false,
- dashboard: null,
- filter: "all",
- query: "",
- sortKey: "streak",
- sortDirection: "desc",
- brokenQuery: "",
- brokenSortKey: "",
- brokenSortDirection: "desc",
- downQuery: "",
- downSortKey: "",
- downSortDirection: "asc",
- yesterdayFilter: "all",
- yesterdayQuery: "",
- yesterdaySortKey: "",
- yesterdaySortDirection: "desc",
- activeView: "sentimentCycleView",
- dragonTiger: null,
- dragonViewMode: "daily",
- dragonFilter: "all",
- dragonQuery: "",
- selectedDragonTraderId: "",
- hotMoneyProfiles: null,
- hotMoneyProfileQuery: "",
- selectedHotMoneyProfileId: "",
- rotationHistory: null,
- rotationHistoryKey: "",
- rotationSelectedSector: "",
- rotationSelectedDate: "",
- rotationMembers: null,
- rotationMembersKey: "",
- rotationMembersLoading: false,
- rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
- rotationLoading: false,
- auctionData: null,
- auctionDataset: "focus",
- auctionFilter: "all",
- auctionQuery: "",
- auctionSortKey: "attention_score",
- auctionSortDirection: "desc",
- auctionLoading: false,
- auctionTimer: null,
- themeLibrary: null,
- themeQuery: "",
- selectedThemeCode: "",
- themeDetail: null,
- themeLoading: false,
- popularityData: null,
- popularitySource: "combined",
- popularityQuery: "",
- popularityLoading: false,
- expandedLadderLevels: new Set(),
- ladderSortMode: "time",
- stockDetail: null,
- activeStock: null,
- stockDetailChartMode: "daily",
- stockDetailIntraday: null,
- stockDetailRequestSequence: 0,
- entityDetailItem: null,
- entityDetailPayload: null,
- entityDetailChartMode: "daily",
- entityDetailIntraday: null,
- entityDetailRequestSequence: 0,
- stockPreviewCode: "",
- stockPreviewType: "stock",
- stockPreviewItem: null,
- stockPreviewPayload: null,
- stockPreviewChart: "daily",
- stockPreviewFallback: null,
- watchlist: [],
- watchlistSelection: null,
- watchlistSearchResults: [],
- watchlistSearchRequestSequence: 0,
- editingDailyNoteId: 0,
- notes: [],
- tradeEntries: [],
- tradeSummary: {},
- editingTradeId: 0,
- initialStockOpened: false,
- screenerSetup: null,
- screenerSetupKey: "",
- screenerSetupRequestKey: "",
- screenerSetupPromise: null,
- selectedRegime: "",
- selectedStrategy: null,
- customStrategyDraft: null,
- screenerRunning: false,
- screenerRunningMode: "",
- screenerResults: { smart: null, curated: null, quant: null },
- screenerResultContexts: { smart: null, curated: null, quant: null },
- screenerResultStore: {},
- screenerTracking: null,
- screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
- ? localStorage.getItem("xiaobaiScreenerMode")
- : "smart",
- curatedCategory: "全部",
- curatedSchool: "全部",
- curatedQuery: "",
- curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
- selectedCuratedStrategyId: 0,
- quantFilters: [],
- quantScores: [],
- alerts: [],
- alertFilter: "all",
- alertUnreadCount: 0,
- assistantMessages: [],
- assistantLoading: false,
- assistantController: null,
- screenerMobileView: "strategy",
- sentimentHistory: null,
- sentimentRange: 20,
- sentimentHistoryKey: "",
- sentimentLoading: false,
- mentorSetup: null,
- selectedMentorId: "",
- mentorMessages: [],
- mentorLoading: false,
- mentorQuery: "",
- mentorGrade: "all",
- mentorDirectoryOpen: false,
- mentorSortMode: false,
- mentorSavingPreferences: false,
- mentorController: null,
- heavenSetup: null,
- heavenManualData: null,
- personalField: null,
- heavenPanel: "trend",
- heavenInterpretations: { trend: "", fortune: "", heart: "" },
- heavenReadingMode: "trend",
- heavenReadingTab: "current",
- heavenReadingHistory: { trend: [], fortune: [], heart: [] },
- heavenReadingSelectedId: 0,
- heavenReadingLoading: false,
- heavenReadingError: "",
- heartStage: "intro",
- heartTimer: null,
- heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
- heartBreathingEndsAt: 0,
- heartLines: [],
- heartThrows: [],
- heartHexagram: null,
- heartCurtainTimer: null,
- heartStageToken: 0,
- heartRevealToken: 0,
- heavenPerformanceKey: "",
- heavenPerformancePanels: new Set(),
- heavenPerformanceActive: "",
- dashboardLoading: false,
- dashboardRequestSequence: 0,
- heavenRequestSequence: 0,
- dashboardRequestDate: "",
- adminModels: [],
- globalSearchResults: [],
- globalSearchActiveIndex: -1,
- globalSearchRequestSequence: 0,
-};
+const state = window.XiaobaiState.create({
+ session: {
+ user: null,
+ csrfToken: "",
+ authMode: "login",
+ started: false,
+ activeView: "sentimentCycleView",
+ dashboardLoading: false,
+ dashboardRequestSequence: 0,
+ dashboardRequestDate: "",
+ adminModels: [],
+ globalSearchResults: [],
+ globalSearchActiveIndex: -1,
+ globalSearchRequestSequence: 0,
+ },
+ market: {
+ dashboard: null,
+ filter: "all",
+ query: "",
+ sortKey: "streak",
+ sortDirection: "desc",
+ brokenQuery: "",
+ brokenSortKey: "",
+ brokenSortDirection: "desc",
+ downQuery: "",
+ downSortKey: "",
+ downSortDirection: "asc",
+ yesterdayFilter: "all",
+ yesterdayQuery: "",
+ yesterdaySortKey: "",
+ yesterdaySortDirection: "desc",
+ dragonTiger: null,
+ dragonViewMode: "daily",
+ dragonFilter: "all",
+ dragonQuery: "",
+ selectedDragonTraderId: "",
+ hotMoneyProfiles: null,
+ hotMoneyProfileQuery: "",
+ selectedHotMoneyProfileId: "",
+ rotationHistory: null,
+ rotationHistoryKey: "",
+ rotationSelectedSector: "",
+ rotationSelectedDate: "",
+ rotationMembers: null,
+ rotationMembersKey: "",
+ rotationMembersLoading: false,
+ rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
+ rotationLoading: false,
+ auctionData: null,
+ auctionDataset: "focus",
+ auctionFilter: "all",
+ auctionQuery: "",
+ auctionSortKey: "attention_score",
+ auctionSortDirection: "desc",
+ auctionLoading: false,
+ auctionTimer: null,
+ themeLibrary: null,
+ themeQuery: "",
+ selectedThemeCode: "",
+ themeDetail: null,
+ themeLoading: false,
+ popularityData: null,
+ popularitySource: "combined",
+ popularityQuery: "",
+ popularityLoading: false,
+ expandedLadderLevels: new Set(),
+ ladderSortMode: "time",
+ sentimentHistory: null,
+ sentimentRange: 20,
+ sentimentHistoryKey: "",
+ sentimentLoading: false,
+ },
+ details: {
+ stockDetail: null,
+ activeStock: null,
+ stockDetailChartMode: "daily",
+ stockDetailIntraday: null,
+ stockDetailRequestSequence: 0,
+ entityDetailItem: null,
+ entityDetailPayload: null,
+ entityDetailChartMode: "daily",
+ entityDetailIntraday: null,
+ entityDetailRequestSequence: 0,
+ stockPreviewCode: "",
+ stockPreviewType: "stock",
+ stockPreviewItem: null,
+ stockPreviewPayload: null,
+ stockPreviewChart: "daily",
+ stockPreviewFallback: null,
+ initialStockOpened: false,
+ },
+ review: {
+ watchlist: [],
+ watchlistSelection: null,
+ watchlistSearchResults: [],
+ watchlistSearchRequestSequence: 0,
+ editingDailyNoteId: 0,
+ notes: [],
+ tradeEntries: [],
+ tradeSummary: {},
+ editingTradeId: 0,
+ alerts: [],
+ alertFilter: "all",
+ alertUnreadCount: 0,
+ assistantMessages: [],
+ assistantLoading: false,
+ assistantController: null,
+ },
+ screener: {
+ screenerSetup: null,
+ screenerSetupKey: "",
+ screenerSetupRequestKey: "",
+ screenerSetupPromise: null,
+ selectedRegime: "",
+ selectedStrategy: null,
+ customStrategyDraft: null,
+ screenerRunning: false,
+ screenerRunningMode: "",
+ screenerResults: { smart: null, curated: null, quant: null },
+ screenerResultContexts: { smart: null, curated: null, quant: null },
+ screenerResultStore: {},
+ screenerTracking: null,
+ screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
+ ? localStorage.getItem("xiaobaiScreenerMode")
+ : "smart",
+ curatedCategory: "全部",
+ curatedSchool: "全部",
+ curatedQuery: "",
+ curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
+ selectedCuratedStrategyId: 0,
+ quantFilters: [],
+ quantScores: [],
+ screenerMobileView: "strategy",
+ },
+ mentor: {
+ mentorSetup: null,
+ selectedMentorId: "",
+ mentorMessages: [],
+ mentorLoading: false,
+ mentorQuery: "",
+ mentorGrade: "all",
+ mentorDirectoryOpen: false,
+ mentorSortMode: false,
+ mentorSavingPreferences: false,
+ mentorController: null,
+ },
+ heaven: {
+ heavenSetup: null,
+ heavenManualData: null,
+ personalField: null,
+ heavenPanel: "trend",
+ heavenInterpretations: { trend: "", fortune: "", heart: "" },
+ heavenReadingMode: "trend",
+ heavenReadingTab: "current",
+ heavenReadingHistory: { trend: [], fortune: [], heart: [] },
+ heavenReadingSelectedId: 0,
+ heavenReadingLoading: false,
+ heavenReadingError: "",
+ heartStage: "intro",
+ heartTimer: null,
+ heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
+ heartBreathingEndsAt: 0,
+ heartLines: [],
+ heartThrows: [],
+ heartHexagram: null,
+ heartCurtainTimer: null,
+ heartStageToken: 0,
+ heartRevealToken: 0,
+ heavenPerformanceKey: "",
+ heavenPerformancePanels: new Set(),
+ heavenPerformanceActive: "",
+ heavenRequestSequence: 0,
+ },
+});
+
+window.XiaobaiAPI.configure({
+ csrfToken: () => state.csrfToken,
+ onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
+});
const elements = {
tradeDate: document.querySelector("#tradeDate"),
@@ -4698,39 +4717,16 @@ function scheduleMentorRender() {
}
async function streamMentorRequest(body, signal, onDelta, onMeta) {
- const response = await fetch("/api/mentors/chat", {
+ await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
- },
- body: JSON.stringify(body),
+ body,
signal,
+ errorMessage: "问师暂不可用",
+ onEvent: (event) => {
+ if (event.type === "delta") onDelta(String(event.content || ""));
+ if (event.type === "meta") onMeta(event);
+ },
});
- if (!response.ok) {
- const payload = await response.json().catch(() => ({}));
- throw new Error(payload.error || "问师暂不可用");
- }
- if (!response.body) throw new Error("当前浏览器不支持流式回答");
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- const consume = (line) => {
- if (!line.trim()) return;
- const event = JSON.parse(line);
- if (event.type === "delta") onDelta(String(event.content || ""));
- if (event.type === "meta") onMeta(event);
- if (event.type === "error") throw new Error(event.error || "问师回答失败");
- };
- while (true) {
- const { value, done } = await reader.read();
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
- lines.forEach(consume);
- if (done) break;
- }
- if (buffer.trim()) consume(buffer);
}
function useMentorQuickPrompt(prompt) {
@@ -7907,41 +7903,15 @@ async function sendAssistantQuestion(event) {
}
async function streamAssistantRequest(question, signal, onDelta) {
- const response = await fetch("/api/assistant/chat", {
+ await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
- },
- body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
+ body: { question, trade_date: elements.tradeDate.value },
signal,
- });
- if (!response.ok) {
- const payload = await response.json().catch(() => ({}));
- throw new Error(payload.error || "复盘助手暂不可用");
- }
- if (!response.body) throw new Error("当前浏览器不支持流式回答");
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- while (true) {
- const { value, done } = await reader.read();
- buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
- const lines = buffer.split("\n");
- buffer = lines.pop() || "";
- for (const line of lines) {
- if (!line.trim()) continue;
- const event = JSON.parse(line);
+ errorMessage: "复盘助手暂不可用",
+ onEvent: (event) => {
if (event.type === "delta") onDelta(String(event.content || ""));
- if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
- }
- if (done) break;
- }
- if (buffer.trim()) {
- const event = JSON.parse(buffer);
- if (event.type === "delta") onDelta(String(event.content || ""));
- if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
- }
+ },
+ });
}
function stopAssistantResponse() {
@@ -9167,21 +9137,7 @@ function formatMoneyMillion(value) {
}
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
- const options = { method, headers: {}, signal: requestOptions.signal };
- if (method !== "GET" && state.csrfToken) {
- options.headers["X-CSRF-Token"] = state.csrfToken;
- }
- if (body !== null) {
- options.headers["Content-Type"] = "application/json";
- options.body = JSON.stringify(body);
- }
- const response = await fetch(url, options);
- const payload = await response.json();
- if (response.status === 401 && !url.startsWith("/api/auth/")) {
- showAuthGate("登录状态已失效,请重新登录。");
- }
- if (!response.ok || payload.error) throw new Error(payload.error || "请求失败");
- return payload;
+ return window.XiaobaiAPI.request(url, method, body, requestOptions);
}
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
diff --git a/static/index.html b/static/index.html
index 4912074..516d6b8 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1865,7 +1865,9 @@
+
+
-
+