@brainpilot/web 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +191 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +188 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +136 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { setActiveLocale } from "../i18n/translate";
|
|
3
|
+
|
|
4
|
+
type ThemePreference = "light" | "dark" | "system";
|
|
5
|
+
|
|
6
|
+
interface PreferencesState {
|
|
7
|
+
theme: ThemePreference;
|
|
8
|
+
resolvedTheme: "light" | "dark";
|
|
9
|
+
language: "zh-CN" | "en-US";
|
|
10
|
+
security: {
|
|
11
|
+
confirmDangerousActions: boolean;
|
|
12
|
+
rememberSession: boolean;
|
|
13
|
+
};
|
|
14
|
+
notifications: {
|
|
15
|
+
agentDone: boolean;
|
|
16
|
+
errors: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PreferencesContextValue extends PreferencesState {
|
|
21
|
+
setTheme: (theme: ThemePreference) => void;
|
|
22
|
+
setLanguage: (language: PreferencesState["language"]) => void;
|
|
23
|
+
setSecurity: (security: PreferencesState["security"]) => void;
|
|
24
|
+
setNotifications: (notifications: PreferencesState["notifications"]) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STORAGE_KEY = "new-ui-preferences";
|
|
28
|
+
|
|
29
|
+
const defaultPreferences: PreferencesState = {
|
|
30
|
+
theme: "light",
|
|
31
|
+
resolvedTheme: "light",
|
|
32
|
+
language: "zh-CN",
|
|
33
|
+
security: {
|
|
34
|
+
confirmDangerousActions: true,
|
|
35
|
+
rememberSession: true,
|
|
36
|
+
},
|
|
37
|
+
notifications: {
|
|
38
|
+
agentDone: true,
|
|
39
|
+
errors: true,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const PreferencesContext = createContext<PreferencesContextValue | null>(null);
|
|
44
|
+
|
|
45
|
+
function loadPreferences(): PreferencesState {
|
|
46
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
47
|
+
if (!stored) {
|
|
48
|
+
return defaultPreferences;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(stored) as Partial<PreferencesState>;
|
|
52
|
+
return {
|
|
53
|
+
...defaultPreferences,
|
|
54
|
+
...parsed,
|
|
55
|
+
security: { ...defaultPreferences.security, ...parsed.security },
|
|
56
|
+
notifications: { ...defaultPreferences.notifications, ...parsed.notifications },
|
|
57
|
+
resolvedTheme: defaultPreferences.resolvedTheme,
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return defaultPreferences;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveTheme(theme: ThemePreference): "light" | "dark" {
|
|
65
|
+
if (theme === "system") {
|
|
66
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
67
|
+
}
|
|
68
|
+
return theme;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function PreferencesProvider({ children }: { children: ReactNode }) {
|
|
72
|
+
const [preferences, setPreferences] = useState<PreferencesState>(() => {
|
|
73
|
+
const loaded = loadPreferences();
|
|
74
|
+
return { ...loaded, resolvedTheme: resolveTheme(loaded.theme) };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const root = document.documentElement;
|
|
79
|
+
root.dataset.theme = preferences.resolvedTheme;
|
|
80
|
+
root.lang = preferences.language;
|
|
81
|
+
setActiveLocale(preferences.language);
|
|
82
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...preferences, resolvedTheme: undefined }));
|
|
83
|
+
}, [preferences]);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (preferences.theme !== "system") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
90
|
+
const listener = () => setPreferences((current) => ({ ...current, resolvedTheme: resolveTheme(current.theme) }));
|
|
91
|
+
media.addEventListener("change", listener);
|
|
92
|
+
return () => media.removeEventListener("change", listener);
|
|
93
|
+
}, [preferences.theme]);
|
|
94
|
+
|
|
95
|
+
const setTheme = useCallback((theme: ThemePreference) => {
|
|
96
|
+
setPreferences((current) => ({ ...current, theme, resolvedTheme: resolveTheme(theme) }));
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const setLanguage = useCallback((language: PreferencesState["language"]) => {
|
|
100
|
+
setPreferences((current) => ({ ...current, language }));
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const setSecurity = useCallback((security: PreferencesState["security"]) => {
|
|
104
|
+
setPreferences((current) => ({ ...current, security }));
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const setNotifications = useCallback((notifications: PreferencesState["notifications"]) => {
|
|
108
|
+
setPreferences((current) => ({ ...current, notifications }));
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const value = useMemo(
|
|
112
|
+
() => ({ ...preferences, setTheme, setLanguage, setSecurity, setNotifications }),
|
|
113
|
+
[preferences, setTheme, setLanguage, setSecurity, setNotifications],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usePreferences() {
|
|
120
|
+
const value = useContext(PreferencesContext);
|
|
121
|
+
if (!value) {
|
|
122
|
+
throw new Error("usePreferences must be used within PreferencesProvider");
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { runtimeConfig } from "../config";
|
|
3
|
+
import { WebSocketEvent, normalizeWebSocketEvent } from "../contracts/backend";
|
|
4
|
+
import { getSSEUrl } from "../utils/api";
|
|
5
|
+
import { tg } from "../i18n/translate";
|
|
6
|
+
import { useAuth } from "./AuthContext";
|
|
7
|
+
import { useSandbox } from "./SandboxContext";
|
|
8
|
+
|
|
9
|
+
type ConnectionStatus = "idle" | "connecting" | "open" | "error";
|
|
10
|
+
|
|
11
|
+
interface SSEContextValue {
|
|
12
|
+
/** Open (or reuse) an SSE stream for a session. */
|
|
13
|
+
connectSession: (sessionId: string) => void;
|
|
14
|
+
/** Close the SSE stream for a session. */
|
|
15
|
+
disconnectSession: (sessionId: string) => void;
|
|
16
|
+
/** Pending event queue per session (drained by consumer). */
|
|
17
|
+
queueRef: React.RefObject<Map<string, WebSocketEvent[]>>;
|
|
18
|
+
/** Tick counter — increments when new events arrive. */
|
|
19
|
+
tick: number;
|
|
20
|
+
/** Connection status per session. */
|
|
21
|
+
connections: Map<string, ConnectionStatus>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SSEContext = createContext<SSEContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
const RECONNECT_BASE_MS = 3000;
|
|
27
|
+
const RECONNECT_MAX_MS = 30000;
|
|
28
|
+
// #106: if an EventSource never fires `onopen` within this window we treat the
|
|
29
|
+
// connection as dead and force a rebuild. A frozen tab / bfcache restore can
|
|
30
|
+
// leave a stale source stuck in CONNECTING whose onopen/onerror never fire
|
|
31
|
+
// again — without this watchdog the UI sits on "正在连接实时通道" forever.
|
|
32
|
+
const OPEN_WATCHDOG_MS = 8000;
|
|
33
|
+
|
|
34
|
+
interface SessionConn {
|
|
35
|
+
source: EventSource;
|
|
36
|
+
reconnectAttempt: number;
|
|
37
|
+
reconnectTimer: number | null;
|
|
38
|
+
/** #106: fires if onopen doesn't arrive in time — forces a reconnect. */
|
|
39
|
+
openWatchdog: number | null;
|
|
40
|
+
/** Whether disconnectSession was called — disable auto-reconnect. */
|
|
41
|
+
manuallyClosed: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function SSEProvider({ children }: { children: ReactNode }) {
|
|
45
|
+
const { isAuthReady } = useAuth();
|
|
46
|
+
const { currentSandbox } = useSandbox();
|
|
47
|
+
const connsRef = useRef<Map<string, SessionConn>>(new Map());
|
|
48
|
+
const queueRef = useRef<Map<string, WebSocketEvent[]>>(new Map());
|
|
49
|
+
const [tick, setTick] = useState(0);
|
|
50
|
+
const [connections, setConnections] = useState<Map<string, ConnectionStatus>>(new Map());
|
|
51
|
+
|
|
52
|
+
const setStatus = useCallback((sessionId: string, status: ConnectionStatus) => {
|
|
53
|
+
setConnections((prev) => {
|
|
54
|
+
const next = new Map(prev);
|
|
55
|
+
next.set(sessionId, status);
|
|
56
|
+
return next;
|
|
57
|
+
});
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
const openConnection = useCallback((sessionId: string) => {
|
|
61
|
+
if (runtimeConfig.useMockBackend) {
|
|
62
|
+
setStatus(sessionId, "open");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const conn = connsRef.current.get(sessionId);
|
|
66
|
+
if (conn && !conn.manuallyClosed && conn.source.readyState !== EventSource.CLOSED) {
|
|
67
|
+
// Already connecting/open — nothing to do.
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A stale entry may exist (e.g. watchdog-forced rebuild) — clear its timers
|
|
72
|
+
// and close its source before replacing it.
|
|
73
|
+
if (conn) {
|
|
74
|
+
if (conn.reconnectTimer !== null) window.clearTimeout(conn.reconnectTimer);
|
|
75
|
+
if (conn.openWatchdog !== null) window.clearTimeout(conn.openWatchdog);
|
|
76
|
+
try {
|
|
77
|
+
conn.source.close();
|
|
78
|
+
} catch {
|
|
79
|
+
/* already closed */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log(`[SSE] openConnection: ${sessionId}`);
|
|
84
|
+
setStatus(sessionId, "connecting");
|
|
85
|
+
const source = new EventSource(getSSEUrl(sessionId));
|
|
86
|
+
|
|
87
|
+
const entry: SessionConn = {
|
|
88
|
+
source,
|
|
89
|
+
reconnectAttempt: conn?.reconnectAttempt ?? 0,
|
|
90
|
+
reconnectTimer: null,
|
|
91
|
+
openWatchdog: null,
|
|
92
|
+
manuallyClosed: false,
|
|
93
|
+
};
|
|
94
|
+
connsRef.current.set(sessionId, entry);
|
|
95
|
+
|
|
96
|
+
// #106: if onopen never lands, the connection is wedged. Tear it down and
|
|
97
|
+
// reconnect through the normal backoff path so the composer doesn't stay
|
|
98
|
+
// disabled on a dead "connecting" state.
|
|
99
|
+
entry.openWatchdog = window.setTimeout(() => {
|
|
100
|
+
entry.openWatchdog = null;
|
|
101
|
+
if (entry.manuallyClosed) return;
|
|
102
|
+
if (entry.source.readyState === EventSource.OPEN) return;
|
|
103
|
+
console.warn(`[SSE] open watchdog fired for ${sessionId} — forcing reconnect`);
|
|
104
|
+
try {
|
|
105
|
+
entry.source.close();
|
|
106
|
+
} catch {
|
|
107
|
+
/* already closed */
|
|
108
|
+
}
|
|
109
|
+
setStatus(sessionId, "error");
|
|
110
|
+
entry.reconnectAttempt += 1;
|
|
111
|
+
const delay = Math.min(
|
|
112
|
+
RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
|
|
113
|
+
RECONNECT_MAX_MS,
|
|
114
|
+
);
|
|
115
|
+
entry.reconnectTimer = window.setTimeout(() => {
|
|
116
|
+
entry.reconnectTimer = null;
|
|
117
|
+
openConnection(sessionId);
|
|
118
|
+
}, delay);
|
|
119
|
+
}, OPEN_WATCHDOG_MS);
|
|
120
|
+
|
|
121
|
+
source.onopen = () => {
|
|
122
|
+
entry.reconnectAttempt = 0;
|
|
123
|
+
if (entry.openWatchdog !== null) {
|
|
124
|
+
window.clearTimeout(entry.openWatchdog);
|
|
125
|
+
entry.openWatchdog = null;
|
|
126
|
+
}
|
|
127
|
+
console.log(`[SSE] onopen: ${sessionId}`);
|
|
128
|
+
setStatus(sessionId, "open");
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
source.onmessage = (ev) => {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(ev.data);
|
|
134
|
+
const normalized = normalizeWebSocketEvent(parsed);
|
|
135
|
+
// Drop heartbeats — they exist only to keep the connection alive.
|
|
136
|
+
if (normalized.type === "PING" || normalized.type === "ping") return;
|
|
137
|
+
console.log(`[SSE] onmessage: ${sessionId} type=${normalized.type}`, normalized);
|
|
138
|
+
const queue = queueRef.current.get(sessionId) || [];
|
|
139
|
+
queue.push(normalized);
|
|
140
|
+
queueRef.current.set(sessionId, queue);
|
|
141
|
+
setTick((t) => t + 1);
|
|
142
|
+
} catch {
|
|
143
|
+
console.error(`[SSE] onmessage: ${sessionId} 解析失败`, ev.data);
|
|
144
|
+
// Malformed payload — surface as a synthetic error event for the UI.
|
|
145
|
+
const queue = queueRef.current.get(sessionId) || [];
|
|
146
|
+
queue.push({
|
|
147
|
+
type: "error",
|
|
148
|
+
sessionId,
|
|
149
|
+
data: { error: { message: tg("ctx.sse.parseError") } },
|
|
150
|
+
});
|
|
151
|
+
queueRef.current.set(sessionId, queue);
|
|
152
|
+
setTick((t) => t + 1);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
source.onerror = () => {
|
|
157
|
+
console.error(`[SSE] onerror: ${sessionId}, reconnectAttempt=${entry.reconnectAttempt + 1}`);
|
|
158
|
+
if (entry.openWatchdog !== null) {
|
|
159
|
+
window.clearTimeout(entry.openWatchdog);
|
|
160
|
+
entry.openWatchdog = null;
|
|
161
|
+
}
|
|
162
|
+
setStatus(sessionId, "error");
|
|
163
|
+
source.close();
|
|
164
|
+
if (entry.manuallyClosed) return;
|
|
165
|
+
// Exponential backoff for auto-reconnect.
|
|
166
|
+
entry.reconnectAttempt += 1;
|
|
167
|
+
const delay = Math.min(
|
|
168
|
+
RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
|
|
169
|
+
RECONNECT_MAX_MS,
|
|
170
|
+
);
|
|
171
|
+
console.log(`[SSE] reconnect: ${sessionId} in ${delay}ms`);
|
|
172
|
+
entry.reconnectTimer = window.setTimeout(() => {
|
|
173
|
+
entry.reconnectTimer = null;
|
|
174
|
+
openConnection(sessionId);
|
|
175
|
+
}, delay);
|
|
176
|
+
};
|
|
177
|
+
}, [setStatus]);
|
|
178
|
+
|
|
179
|
+
const connectSession = useCallback((sessionId: string) => {
|
|
180
|
+
console.log(`[SSE] connectSession: ${sessionId}, authReady=${isAuthReady}, sandbox=${currentSandbox?.status}`);
|
|
181
|
+
if (!isAuthReady) return;
|
|
182
|
+
if (!runtimeConfig.useMockBackend && currentSandbox?.status !== "running") return;
|
|
183
|
+
openConnection(sessionId);
|
|
184
|
+
}, [isAuthReady, currentSandbox?.status, openConnection]);
|
|
185
|
+
|
|
186
|
+
const disconnectSession = useCallback((sessionId: string) => {
|
|
187
|
+
console.log(`[SSE] disconnectSession: ${sessionId}`);
|
|
188
|
+
const entry = connsRef.current.get(sessionId);
|
|
189
|
+
if (!entry) return;
|
|
190
|
+
entry.manuallyClosed = true;
|
|
191
|
+
if (entry.reconnectTimer !== null) {
|
|
192
|
+
window.clearTimeout(entry.reconnectTimer);
|
|
193
|
+
entry.reconnectTimer = null;
|
|
194
|
+
}
|
|
195
|
+
if (entry.openWatchdog !== null) {
|
|
196
|
+
window.clearTimeout(entry.openWatchdog);
|
|
197
|
+
entry.openWatchdog = null;
|
|
198
|
+
}
|
|
199
|
+
entry.source.close();
|
|
200
|
+
connsRef.current.delete(sessionId);
|
|
201
|
+
setStatus(sessionId, "idle");
|
|
202
|
+
}, [setStatus]);
|
|
203
|
+
|
|
204
|
+
// On unmount or auth/sandbox change, tear down every connection.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
return () => {
|
|
207
|
+
for (const [, entry] of connsRef.current) {
|
|
208
|
+
entry.manuallyClosed = true;
|
|
209
|
+
if (entry.reconnectTimer !== null) {
|
|
210
|
+
window.clearTimeout(entry.reconnectTimer);
|
|
211
|
+
}
|
|
212
|
+
if (entry.openWatchdog !== null) {
|
|
213
|
+
window.clearTimeout(entry.openWatchdog);
|
|
214
|
+
}
|
|
215
|
+
entry.source.close();
|
|
216
|
+
}
|
|
217
|
+
connsRef.current.clear();
|
|
218
|
+
};
|
|
219
|
+
}, [isAuthReady, currentSandbox?.status]);
|
|
220
|
+
|
|
221
|
+
// #106: bfcache / frozen-tab restore can leave an EventSource that looks
|
|
222
|
+
// alive (readyState !== CLOSED) but whose onopen/onerror never fire again, so
|
|
223
|
+
// the composer stays stuck on "connecting". On page restore or tab
|
|
224
|
+
// re-focus, force any non-open connection to rebuild. The browser-native
|
|
225
|
+
// `pageshow` (persisted) covers bfcache; `visibilitychange` covers the more
|
|
226
|
+
// common "switched away and back" case.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const revive = () => {
|
|
229
|
+
for (const [sessionId, entry] of connsRef.current) {
|
|
230
|
+
if (entry.manuallyClosed) continue;
|
|
231
|
+
if (entry.source.readyState === EventSource.OPEN) continue;
|
|
232
|
+
console.log(`[SSE] revive stale connection on restore: ${sessionId}`);
|
|
233
|
+
openConnection(sessionId);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const onPageShow = (event: PageTransitionEvent) => {
|
|
237
|
+
if (event.persisted) revive();
|
|
238
|
+
};
|
|
239
|
+
const onVisibility = () => {
|
|
240
|
+
if (document.visibilityState === "visible") revive();
|
|
241
|
+
};
|
|
242
|
+
window.addEventListener("pageshow", onPageShow);
|
|
243
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
244
|
+
return () => {
|
|
245
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
246
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
247
|
+
};
|
|
248
|
+
}, [openConnection]);
|
|
249
|
+
|
|
250
|
+
const value = useMemo<SSEContextValue>(
|
|
251
|
+
() => ({ connectSession, disconnectSession, queueRef, tick, connections }),
|
|
252
|
+
[connectSession, disconnectSession, tick, connections],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return <SSEContext.Provider value={value}>{children}</SSEContext.Provider>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function useSSE() {
|
|
259
|
+
const value = useContext(SSEContext);
|
|
260
|
+
if (!value) {
|
|
261
|
+
throw new Error("useSSE must be used within SSEProvider");
|
|
262
|
+
}
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Sandbox, SandboxStats } from "../contracts/backend";
|
|
3
|
+
import { api } from "../utils/api";
|
|
4
|
+
import { tg } from "../i18n/translate";
|
|
5
|
+
import { useAuth } from "./AuthContext";
|
|
6
|
+
import { runtimeConfig } from "../config";
|
|
7
|
+
|
|
8
|
+
export type SandboxOperation = "idle" | "loading" | "creating" | "rebuilding" | "destroying";
|
|
9
|
+
|
|
10
|
+
interface SandboxContextValue {
|
|
11
|
+
sandboxes: Sandbox[];
|
|
12
|
+
currentSandbox: Sandbox | null;
|
|
13
|
+
stats: SandboxStats | null;
|
|
14
|
+
status: string;
|
|
15
|
+
operation: SandboxOperation;
|
|
16
|
+
error: string | null;
|
|
17
|
+
logs: string;
|
|
18
|
+
refresh: () => Promise<void>;
|
|
19
|
+
refreshStats: () => Promise<void>;
|
|
20
|
+
createSandbox: () => Promise<void>;
|
|
21
|
+
rebuildSandbox: () => Promise<void>;
|
|
22
|
+
destroySandbox: () => Promise<void>;
|
|
23
|
+
reloadConfig: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SandboxContext = createContext<SandboxContextValue | null>(null);
|
|
27
|
+
|
|
28
|
+
function selectSandbox(sandboxes: Sandbox[]): Sandbox | null {
|
|
29
|
+
// Skip "error" rows: they're stale corpses from a previous container that
|
|
30
|
+
// the backend's _sync_running_status conservatively flipped to ERROR only
|
|
31
|
+
// after confirming the container was not_found / exited / dead / removing.
|
|
32
|
+
// Treating them as currentSandbox would block auto-create and leave the
|
|
33
|
+
// user stuck on "沙盒状态异常" until they manually clicked Build.
|
|
34
|
+
const live = sandboxes.filter((sandbox) => sandbox.status !== "error");
|
|
35
|
+
return live.find((sandbox) => sandbox.status === "running") ?? live[0] ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SandboxProvider({ children }: { children: ReactNode }) {
|
|
39
|
+
// Single-user local mode: there is no container lifecycle. The runtime
|
|
40
|
+
// launched by `brainpilot up` is always the sandbox, addressed per-session by
|
|
41
|
+
// the file routes. We expose a static "running" sandbox so the composer and
|
|
42
|
+
// file panels work, and never call /api/sandbox/* lifecycle endpoints (which
|
|
43
|
+
// the local backend does not implement). File operations resolve the real
|
|
44
|
+
// workspace by the active session id inside the file components themselves.
|
|
45
|
+
if (runtimeConfig.localMode) {
|
|
46
|
+
return <LocalSandboxProvider>{children}</LocalSandboxProvider>;
|
|
47
|
+
}
|
|
48
|
+
return <RemoteSandboxProvider>{children}</RemoteSandboxProvider>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const LOCAL_SANDBOX: Sandbox = {
|
|
52
|
+
id: "local",
|
|
53
|
+
name: "local",
|
|
54
|
+
status: "running",
|
|
55
|
+
port: null,
|
|
56
|
+
userId: "local",
|
|
57
|
+
createdAt: new Date(0).toISOString(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function LocalSandboxProvider({ children }: { children: ReactNode }) {
|
|
61
|
+
const noop = useCallback(async () => {}, []);
|
|
62
|
+
const value = useMemo<SandboxContextValue>(
|
|
63
|
+
() => ({
|
|
64
|
+
sandboxes: [LOCAL_SANDBOX],
|
|
65
|
+
currentSandbox: LOCAL_SANDBOX,
|
|
66
|
+
stats: null,
|
|
67
|
+
status: "running",
|
|
68
|
+
operation: "idle",
|
|
69
|
+
error: null,
|
|
70
|
+
logs: "",
|
|
71
|
+
refresh: noop,
|
|
72
|
+
refreshStats: noop,
|
|
73
|
+
createSandbox: noop,
|
|
74
|
+
rebuildSandbox: noop,
|
|
75
|
+
destroySandbox: noop,
|
|
76
|
+
reloadConfig: noop,
|
|
77
|
+
}),
|
|
78
|
+
[noop],
|
|
79
|
+
);
|
|
80
|
+
return <SandboxContext.Provider value={value}>{children}</SandboxContext.Provider>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function RemoteSandboxProvider({ children }: { children: ReactNode }) {
|
|
84
|
+
const { isAuthReady } = useAuth();
|
|
85
|
+
const [sandboxes, setSandboxes] = useState<Sandbox[]>([]);
|
|
86
|
+
const [currentSandbox, setCurrentSandbox] = useState<Sandbox | null>(null);
|
|
87
|
+
const [stats, setStats] = useState<SandboxStats | null>(null);
|
|
88
|
+
const [operation, setOperation] = useState<SandboxOperation>("idle");
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [logs, setLogs] = useState<string>("");
|
|
91
|
+
const [hasLoaded, setHasLoaded] = useState(false);
|
|
92
|
+
const shouldAutoStartRef = useRef(true);
|
|
93
|
+
|
|
94
|
+
const refresh = useCallback(async () => {
|
|
95
|
+
if (!isAuthReady) {
|
|
96
|
+
setSandboxes([]);
|
|
97
|
+
setCurrentSandbox(null);
|
|
98
|
+
setStats(null);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setOperation((current) => (current === "idle" ? "loading" : current));
|
|
103
|
+
setError(null);
|
|
104
|
+
try {
|
|
105
|
+
const nextSandboxes = await api.sandbox.list();
|
|
106
|
+
setSandboxes(nextSandboxes);
|
|
107
|
+
setCurrentSandbox(selectSandbox(nextSandboxes));
|
|
108
|
+
setHasLoaded(true);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
setError(err instanceof Error ? err.message : tg("ctx.sandbox.loadFailed"));
|
|
111
|
+
} finally {
|
|
112
|
+
setOperation((current) => (current === "loading" ? "idle" : current));
|
|
113
|
+
}
|
|
114
|
+
}, [isAuthReady]);
|
|
115
|
+
|
|
116
|
+
const refreshStats = useCallback(async () => {
|
|
117
|
+
if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
|
|
118
|
+
setStats(null);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
setStats(await api.sandbox.stats(currentSandbox.id));
|
|
124
|
+
} catch {
|
|
125
|
+
setStats(null);
|
|
126
|
+
// 如果 stats 获取失败(容器可能已不存在),刷新列表以同步真实状态
|
|
127
|
+
if (currentSandbox?.status === "running" || currentSandbox?.status === "quota_exceeded") {
|
|
128
|
+
void refresh();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}, [currentSandbox, refresh]);
|
|
132
|
+
|
|
133
|
+
const fetchLogs = useCallback(async () => {
|
|
134
|
+
if (!currentSandbox?.id) return;
|
|
135
|
+
try {
|
|
136
|
+
setLogs(await api.sandbox.logs(currentSandbox.id));
|
|
137
|
+
} catch {
|
|
138
|
+
// 日志获取失败也可能是容器已停止的信号,刷新列表同步状态
|
|
139
|
+
if (currentSandbox?.status === "running") {
|
|
140
|
+
void refresh();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}, [currentSandbox?.id, refresh]);
|
|
144
|
+
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
void refresh();
|
|
147
|
+
}, [refresh]);
|
|
148
|
+
|
|
149
|
+
// Reset auto-start flag once auth becomes ready.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
shouldAutoStartRef.current = true;
|
|
152
|
+
setHasLoaded(false);
|
|
153
|
+
}, [isAuthReady]);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
void refreshStats();
|
|
157
|
+
if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const timer = window.setInterval(() => void refreshStats(), 8000);
|
|
161
|
+
return () => window.clearInterval(timer);
|
|
162
|
+
}, [currentSandbox, refreshStats]);
|
|
163
|
+
|
|
164
|
+
// Poll logs every 3s when running or quota_exceeded
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
void fetchLogs();
|
|
167
|
+
if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const timer = window.setInterval(() => void fetchLogs(), 3000);
|
|
171
|
+
return () => window.clearInterval(timer);
|
|
172
|
+
}, [currentSandbox, fetchLogs]);
|
|
173
|
+
|
|
174
|
+
const createSandbox = useCallback(async () => {
|
|
175
|
+
setOperation("creating");
|
|
176
|
+
setError(null);
|
|
177
|
+
try {
|
|
178
|
+
const sandbox = await api.sandbox.create("default");
|
|
179
|
+
setSandboxes((current) => [sandbox, ...current.filter((item) => item.id !== sandbox.id)]);
|
|
180
|
+
setCurrentSandbox(sandbox);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
setError(err instanceof Error ? err.message : tg("ctx.sandbox.createFailed"));
|
|
183
|
+
throw err;
|
|
184
|
+
} finally {
|
|
185
|
+
setOperation("idle");
|
|
186
|
+
}
|
|
187
|
+
}, []);
|
|
188
|
+
|
|
189
|
+
const rebuildSandbox = useCallback(async () => {
|
|
190
|
+
if (!currentSandbox) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
setOperation("rebuilding");
|
|
194
|
+
setError(null);
|
|
195
|
+
try {
|
|
196
|
+
const sandbox = await api.sandbox.rebuild(currentSandbox.id);
|
|
197
|
+
setSandboxes((current) => current.map((item) => (item.id === sandbox.id ? sandbox : item)));
|
|
198
|
+
setCurrentSandbox(sandbox);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
setError(err instanceof Error ? err.message : tg("ctx.sandbox.rebuildFailed"));
|
|
201
|
+
throw err;
|
|
202
|
+
} finally {
|
|
203
|
+
setOperation("idle");
|
|
204
|
+
}
|
|
205
|
+
}, [currentSandbox]);
|
|
206
|
+
|
|
207
|
+
const destroySandbox = useCallback(async () => {
|
|
208
|
+
if (!currentSandbox) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
setOperation("destroying");
|
|
212
|
+
setError(null);
|
|
213
|
+
try {
|
|
214
|
+
await api.sandbox.destroy(currentSandbox.id);
|
|
215
|
+
const remaining = sandboxes.filter((item) => item.id !== currentSandbox.id);
|
|
216
|
+
setSandboxes(remaining);
|
|
217
|
+
setCurrentSandbox(selectSandbox(remaining));
|
|
218
|
+
setStats(null);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
setError(err instanceof Error ? err.message : tg("ctx.sandbox.deleteFailed"));
|
|
221
|
+
throw err;
|
|
222
|
+
} finally {
|
|
223
|
+
setOperation("idle");
|
|
224
|
+
}
|
|
225
|
+
}, [currentSandbox, sandboxes]);
|
|
226
|
+
|
|
227
|
+
// Reload sandbox config (hot-update) - from master
|
|
228
|
+
const reloadConfig = useCallback(async () => {
|
|
229
|
+
if (!currentSandbox) return;
|
|
230
|
+
try {
|
|
231
|
+
await api.sandbox.reloadConfig(currentSandbox.id);
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
setError(err instanceof Error ? err.message : tg("ctx.sandbox.reloadFailed"));
|
|
234
|
+
}
|
|
235
|
+
}, [currentSandbox]);
|
|
236
|
+
|
|
237
|
+
// Auto-create only when the user truly has no sandbox. Never auto-rebuild on
|
|
238
|
+
// page mount: an existing container is almost always still alive — the row
|
|
239
|
+
// may merely have been flipped to error by a transient Docker hiccup during
|
|
240
|
+
// a previous /api/sandbox/list. If it really did die, SandboxStatus shows
|
|
241
|
+
// "沙盒状态异常" + a manual Rebuild button. Creating / rebuilding are
|
|
242
|
+
// transient backend states and naturally need no action.
|
|
243
|
+
//
|
|
244
|
+
// hasLoaded gate: on initial mount the auto-start effect fires *before* the
|
|
245
|
+
// first refresh() completes (refresh's setOperation('loading') is an async
|
|
246
|
+
// state update, but the effect already runs in the same batch as the
|
|
247
|
+
// auth-ready change). Without this gate currentSandbox is null on first render and we
|
|
248
|
+
// mistakenly call createSandbox() → force-removes the same-named live
|
|
249
|
+
// container. Wait until at least one /api/sandbox/list has returned before
|
|
250
|
+
// making the call.
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (!isAuthReady) return;
|
|
253
|
+
if (!hasLoaded) return;
|
|
254
|
+
if (operation !== "idle") return;
|
|
255
|
+
if (!shouldAutoStartRef.current) return;
|
|
256
|
+
|
|
257
|
+
shouldAutoStartRef.current = false;
|
|
258
|
+
if (!currentSandbox) {
|
|
259
|
+
void createSandbox();
|
|
260
|
+
}
|
|
261
|
+
}, [isAuthReady, hasLoaded, operation, currentSandbox, createSandbox]);
|
|
262
|
+
|
|
263
|
+
const value = useMemo(
|
|
264
|
+
() => ({
|
|
265
|
+
sandboxes,
|
|
266
|
+
currentSandbox,
|
|
267
|
+
stats,
|
|
268
|
+
status:
|
|
269
|
+
currentSandbox?.status ??
|
|
270
|
+
(operation === "creating"
|
|
271
|
+
? "creating"
|
|
272
|
+
: operation === "loading"
|
|
273
|
+
? "loading"
|
|
274
|
+
: "missing"),
|
|
275
|
+
operation,
|
|
276
|
+
error,
|
|
277
|
+
logs,
|
|
278
|
+
refresh,
|
|
279
|
+
refreshStats,
|
|
280
|
+
createSandbox,
|
|
281
|
+
rebuildSandbox,
|
|
282
|
+
destroySandbox,
|
|
283
|
+
reloadConfig,
|
|
284
|
+
}),
|
|
285
|
+
[
|
|
286
|
+
sandboxes,
|
|
287
|
+
currentSandbox,
|
|
288
|
+
stats,
|
|
289
|
+
operation,
|
|
290
|
+
error,
|
|
291
|
+
logs,
|
|
292
|
+
refresh,
|
|
293
|
+
refreshStats,
|
|
294
|
+
createSandbox,
|
|
295
|
+
rebuildSandbox,
|
|
296
|
+
destroySandbox,
|
|
297
|
+
reloadConfig,
|
|
298
|
+
],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return <SandboxContext.Provider value={value}>{children}</SandboxContext.Provider>;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function useSandbox() {
|
|
305
|
+
const value = useContext(SandboxContext);
|
|
306
|
+
if (!value) {
|
|
307
|
+
throw new Error("useSandbox must be used within SandboxProvider");
|
|
308
|
+
}
|
|
309
|
+
return value;
|
|
310
|
+
}
|