@brainpilot/web 0.0.3 → 0.0.5
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-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -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/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 +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -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 +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -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 +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -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 +187 -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 +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -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/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -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 +184 -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 +84 -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 +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -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,175 @@
|
|
|
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
|
+
|
|
29
|
+
interface SessionConn {
|
|
30
|
+
source: EventSource;
|
|
31
|
+
reconnectAttempt: number;
|
|
32
|
+
reconnectTimer: number | null;
|
|
33
|
+
/** Whether disconnectSession was called — disable auto-reconnect. */
|
|
34
|
+
manuallyClosed: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SSEProvider({ children }: { children: ReactNode }) {
|
|
38
|
+
const { isAuthReady } = useAuth();
|
|
39
|
+
const { currentSandbox } = useSandbox();
|
|
40
|
+
const connsRef = useRef<Map<string, SessionConn>>(new Map());
|
|
41
|
+
const queueRef = useRef<Map<string, WebSocketEvent[]>>(new Map());
|
|
42
|
+
const [tick, setTick] = useState(0);
|
|
43
|
+
const [connections, setConnections] = useState<Map<string, ConnectionStatus>>(new Map());
|
|
44
|
+
|
|
45
|
+
const setStatus = useCallback((sessionId: string, status: ConnectionStatus) => {
|
|
46
|
+
setConnections((prev) => {
|
|
47
|
+
const next = new Map(prev);
|
|
48
|
+
next.set(sessionId, status);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const openConnection = useCallback((sessionId: string) => {
|
|
54
|
+
if (runtimeConfig.useMockBackend) {
|
|
55
|
+
setStatus(sessionId, "open");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const conn = connsRef.current.get(sessionId);
|
|
59
|
+
if (conn && !conn.manuallyClosed && conn.source.readyState !== EventSource.CLOSED) {
|
|
60
|
+
// Already connecting/open — nothing to do.
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`[SSE] openConnection: ${sessionId}`);
|
|
65
|
+
setStatus(sessionId, "connecting");
|
|
66
|
+
const source = new EventSource(getSSEUrl(sessionId));
|
|
67
|
+
|
|
68
|
+
const entry: SessionConn = {
|
|
69
|
+
source,
|
|
70
|
+
reconnectAttempt: 0,
|
|
71
|
+
reconnectTimer: null,
|
|
72
|
+
manuallyClosed: false,
|
|
73
|
+
};
|
|
74
|
+
connsRef.current.set(sessionId, entry);
|
|
75
|
+
|
|
76
|
+
source.onopen = () => {
|
|
77
|
+
entry.reconnectAttempt = 0;
|
|
78
|
+
console.log(`[SSE] onopen: ${sessionId}`);
|
|
79
|
+
setStatus(sessionId, "open");
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
source.onmessage = (ev) => {
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(ev.data);
|
|
85
|
+
const normalized = normalizeWebSocketEvent(parsed);
|
|
86
|
+
// Drop heartbeats — they exist only to keep the connection alive.
|
|
87
|
+
if (normalized.type === "PING" || normalized.type === "ping") return;
|
|
88
|
+
console.log(`[SSE] onmessage: ${sessionId} type=${normalized.type}`, normalized);
|
|
89
|
+
const queue = queueRef.current.get(sessionId) || [];
|
|
90
|
+
queue.push(normalized);
|
|
91
|
+
queueRef.current.set(sessionId, queue);
|
|
92
|
+
setTick((t) => t + 1);
|
|
93
|
+
} catch {
|
|
94
|
+
console.error(`[SSE] onmessage: ${sessionId} 解析失败`, ev.data);
|
|
95
|
+
// Malformed payload — surface as a synthetic error event for the UI.
|
|
96
|
+
const queue = queueRef.current.get(sessionId) || [];
|
|
97
|
+
queue.push({
|
|
98
|
+
type: "error",
|
|
99
|
+
sessionId,
|
|
100
|
+
data: { error: { message: tg("ctx.sse.parseError") } },
|
|
101
|
+
});
|
|
102
|
+
queueRef.current.set(sessionId, queue);
|
|
103
|
+
setTick((t) => t + 1);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
source.onerror = () => {
|
|
108
|
+
console.error(`[SSE] onerror: ${sessionId}, reconnectAttempt=${entry.reconnectAttempt + 1}`);
|
|
109
|
+
setStatus(sessionId, "error");
|
|
110
|
+
source.close();
|
|
111
|
+
if (entry.manuallyClosed) return;
|
|
112
|
+
// Exponential backoff for auto-reconnect.
|
|
113
|
+
entry.reconnectAttempt += 1;
|
|
114
|
+
const delay = Math.min(
|
|
115
|
+
RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
|
|
116
|
+
RECONNECT_MAX_MS,
|
|
117
|
+
);
|
|
118
|
+
console.log(`[SSE] reconnect: ${sessionId} in ${delay}ms`);
|
|
119
|
+
entry.reconnectTimer = window.setTimeout(() => {
|
|
120
|
+
entry.reconnectTimer = null;
|
|
121
|
+
openConnection(sessionId);
|
|
122
|
+
}, delay);
|
|
123
|
+
};
|
|
124
|
+
}, [setStatus]);
|
|
125
|
+
|
|
126
|
+
const connectSession = useCallback((sessionId: string) => {
|
|
127
|
+
console.log(`[SSE] connectSession: ${sessionId}, authReady=${isAuthReady}, sandbox=${currentSandbox?.status}`);
|
|
128
|
+
if (!isAuthReady) return;
|
|
129
|
+
if (!runtimeConfig.useMockBackend && currentSandbox?.status !== "running") return;
|
|
130
|
+
openConnection(sessionId);
|
|
131
|
+
}, [isAuthReady, currentSandbox?.status, openConnection]);
|
|
132
|
+
|
|
133
|
+
const disconnectSession = useCallback((sessionId: string) => {
|
|
134
|
+
console.log(`[SSE] disconnectSession: ${sessionId}`);
|
|
135
|
+
const entry = connsRef.current.get(sessionId);
|
|
136
|
+
if (!entry) return;
|
|
137
|
+
entry.manuallyClosed = true;
|
|
138
|
+
if (entry.reconnectTimer !== null) {
|
|
139
|
+
window.clearTimeout(entry.reconnectTimer);
|
|
140
|
+
entry.reconnectTimer = null;
|
|
141
|
+
}
|
|
142
|
+
entry.source.close();
|
|
143
|
+
connsRef.current.delete(sessionId);
|
|
144
|
+
setStatus(sessionId, "idle");
|
|
145
|
+
}, [setStatus]);
|
|
146
|
+
|
|
147
|
+
// On unmount or auth/sandbox change, tear down every connection.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
return () => {
|
|
150
|
+
for (const [, entry] of connsRef.current) {
|
|
151
|
+
entry.manuallyClosed = true;
|
|
152
|
+
if (entry.reconnectTimer !== null) {
|
|
153
|
+
window.clearTimeout(entry.reconnectTimer);
|
|
154
|
+
}
|
|
155
|
+
entry.source.close();
|
|
156
|
+
}
|
|
157
|
+
connsRef.current.clear();
|
|
158
|
+
};
|
|
159
|
+
}, [isAuthReady, currentSandbox?.status]);
|
|
160
|
+
|
|
161
|
+
const value = useMemo<SSEContextValue>(
|
|
162
|
+
() => ({ connectSession, disconnectSession, queueRef, tick, connections }),
|
|
163
|
+
[connectSession, disconnectSession, tick, connections],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
return <SSEContext.Provider value={value}>{children}</SSEContext.Provider>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function useSSE() {
|
|
170
|
+
const value = useContext(SSEContext);
|
|
171
|
+
if (!value) {
|
|
172
|
+
throw new Error("useSSE must be used within SSEProvider");
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
@@ -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
|
+
}
|